Merge pull request #33186 from thezaeemaanwar/remove_badges_app
[DEPR]: lms/djangoapps/badges
This commit is contained in:
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -2,7 +2,6 @@
|
||||
"lms-1": {
|
||||
"settings": "lms.envs.test",
|
||||
"paths": [
|
||||
"lms/djangoapps/badges/",
|
||||
"lms/djangoapps/branding/",
|
||||
"lms/djangoapps/bulk_email/",
|
||||
"lms/djangoapps/bulk_enroll/",
|
||||
|
||||
@@ -126,11 +126,6 @@ class CourseMetadata:
|
||||
exclude_list.append('enable_ccx')
|
||||
exclude_list.append('ccx_connector')
|
||||
|
||||
# Do not show "Issue Open Badges" in Studio Advanced Settings
|
||||
# if the feature is disabled.
|
||||
if not settings.FEATURES.get('ENABLE_OPENBADGES'):
|
||||
exclude_list.append('issue_badges')
|
||||
|
||||
# If the XBlockStudioConfiguration table is not being used, there is no need to
|
||||
# display the "Allow Unsupported XBlocks" setting.
|
||||
if not XBlockStudioConfigurationFlag.is_enabled():
|
||||
|
||||
@@ -123,9 +123,6 @@ FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
|
||||
# shown in Studio in a separate list.
|
||||
FEATURES['ENABLE_SEPARATE_ARCHIVED_COURSES'] = True
|
||||
|
||||
# Enable support for OpenBadges accomplishments
|
||||
FEATURES['ENABLE_OPENBADGES'] = True
|
||||
|
||||
# Enable partner support link in Studio footer
|
||||
PARTNER_SUPPORT_EMAIL = 'partner-support@example.com'
|
||||
|
||||
|
||||
@@ -321,9 +321,6 @@ FEATURES = {
|
||||
# Show video bumper in Studio
|
||||
'ENABLE_VIDEO_BUMPER': False,
|
||||
|
||||
# Show issue open badges in Studio
|
||||
'ENABLE_OPENBADGES': False,
|
||||
|
||||
# How many seconds to show the bumper again, default is 7 days:
|
||||
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from io import StringIO
|
||||
|
||||
import ddt
|
||||
import unittest
|
||||
from django.core.management import call_command
|
||||
from django.db.transaction import TransactionManagementError, atomic
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
@@ -120,6 +121,7 @@ class MigrationTests(TestCase):
|
||||
Tests for migrations.
|
||||
"""
|
||||
|
||||
@unittest.skip("Migration will delete several models. Need to ship not referencing it first")
|
||||
@override_settings(MIGRATION_MODULES={})
|
||||
def test_migrations_are_in_sync(self):
|
||||
"""
|
||||
|
||||
@@ -58,114 +58,6 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/badges/v1/assertions/user/{username}/:
|
||||
get:
|
||||
operationId: badges_v1_assertions_user_read
|
||||
summary: '**Use Cases**'
|
||||
description: |-
|
||||
Request a list of assertions for a user, optionally constrained to a course.
|
||||
|
||||
**Example Requests**
|
||||
|
||||
GET /api/badges/v1/assertions/user/{username}/
|
||||
|
||||
**Response Values**
|
||||
|
||||
Body comprised of a list of objects with the following fields:
|
||||
|
||||
* badge_class: The badge class the assertion was awarded for. Represented as an object
|
||||
with the following fields:
|
||||
* slug: The identifier for the badge class
|
||||
* issuing_component: The software component responsible for issuing this badge.
|
||||
* display_name: The display name of the badge.
|
||||
* course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course.
|
||||
* description: A description of the award and its significance.
|
||||
* criteria: A description of what is needed to obtain this award.
|
||||
* image_url: A URL to the icon image used to represent this award.
|
||||
* image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award
|
||||
in its headers.
|
||||
* assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools
|
||||
and software.
|
||||
|
||||
**Params**
|
||||
|
||||
* slug (optional): The identifier for a particular badge class to filter by.
|
||||
* issuing_component (optional): The issuing component for a particular badge class to filter by
|
||||
(requires slug to have been specified, or this will be ignored.) If slug is provided and this is not,
|
||||
assumes the issuing_component should be empty.
|
||||
* course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is
|
||||
provided, and this field is not specified, assumes that the target badge has an empty course_id field.
|
||||
'*' may be used to get all badges with the specified slug, issuing_component combination across all courses.
|
||||
|
||||
**Returns**
|
||||
|
||||
* 200 on success, with a list of Badge Assertion objects.
|
||||
* 403 if a user who does not have permission to masquerade as
|
||||
another user specifies a username other than their own.
|
||||
* 404 if the specified user does not exist
|
||||
|
||||
{
|
||||
"count": 7,
|
||||
"previous": null,
|
||||
"num_pages": 1,
|
||||
"results": [
|
||||
{
|
||||
"badge_class": {
|
||||
"slug": "special_award",
|
||||
"issuing_component": "openedx__course",
|
||||
"display_name": "Very Special Award",
|
||||
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"description": "Awarded for people who did something incredibly special",
|
||||
"criteria": "Do something incredibly special.",
|
||||
"image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png"
|
||||
},
|
||||
"image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png",
|
||||
"assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
required: false
|
||||
type: integer
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/BadgeAssertion'
|
||||
tags:
|
||||
- badges
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/bookmarks/v1/bookmarks/:
|
||||
get:
|
||||
operationId: bookmarks_v1_bookmarks_list
|
||||
@@ -9477,75 +9369,6 @@ paths:
|
||||
required: true
|
||||
type: string
|
||||
definitions:
|
||||
BadgeClass:
|
||||
required:
|
||||
- slug
|
||||
- display_name
|
||||
- description
|
||||
- criteria
|
||||
type: object
|
||||
properties:
|
||||
slug:
|
||||
title: Slug
|
||||
type: string
|
||||
format: slug
|
||||
pattern: ^[-a-zA-Z0-9_]+$
|
||||
maxLength: 255
|
||||
minLength: 1
|
||||
issuing_component:
|
||||
title: Issuing component
|
||||
type: string
|
||||
format: slug
|
||||
pattern: ^[-a-zA-Z0-9_]+$
|
||||
default: ''
|
||||
maxLength: 50
|
||||
display_name:
|
||||
title: Display name
|
||||
type: string
|
||||
maxLength: 255
|
||||
minLength: 1
|
||||
course_id:
|
||||
title: Course id
|
||||
type: string
|
||||
maxLength: 255
|
||||
description:
|
||||
title: Description
|
||||
type: string
|
||||
minLength: 1
|
||||
criteria:
|
||||
title: Criteria
|
||||
type: string
|
||||
minLength: 1
|
||||
image_url:
|
||||
title: Image url
|
||||
type: string
|
||||
readOnly: true
|
||||
format: uri
|
||||
BadgeAssertion:
|
||||
required:
|
||||
- image_url
|
||||
- assertion_url
|
||||
type: object
|
||||
properties:
|
||||
badge_class:
|
||||
$ref: '#/definitions/BadgeClass'
|
||||
image_url:
|
||||
title: Image url
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 200
|
||||
minLength: 1
|
||||
assertion_url:
|
||||
title: Assertion url
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 200
|
||||
minLength: 1
|
||||
created:
|
||||
title: Created
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
CCXCourse:
|
||||
required:
|
||||
- master_course_id
|
||||
|
||||
@@ -9,7 +9,6 @@ Studio.
|
||||
:maxdepth: 2
|
||||
|
||||
lms/modules
|
||||
lms/djangoapps/badges/modules
|
||||
lms/djangoapps/branding/modules
|
||||
lms/djangoapps/bulk_email/modules
|
||||
lms/djangoapps/courseware/modules
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""
|
||||
Admin registration for Badge Models
|
||||
"""
|
||||
|
||||
|
||||
from config_models.admin import ConfigurationModelAdmin
|
||||
from django.contrib import admin
|
||||
|
||||
from lms.djangoapps.badges.models import (
|
||||
BadgeAssertion,
|
||||
BadgeClass,
|
||||
CourseCompleteImageConfiguration,
|
||||
CourseEventBadgesConfiguration
|
||||
)
|
||||
|
||||
admin.site.register(CourseCompleteImageConfiguration)
|
||||
admin.site.register(BadgeClass)
|
||||
admin.site.register(BadgeAssertion)
|
||||
# Use the standard Configuration Model Admin handler for this model.
|
||||
admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
Serializers for Badges
|
||||
"""
|
||||
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass
|
||||
|
||||
|
||||
class BadgeClassSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for BadgeClass model.
|
||||
"""
|
||||
image_url = serializers.ImageField(source='image')
|
||||
|
||||
class Meta:
|
||||
model = BadgeClass
|
||||
fields = ('slug', 'issuing_component', 'display_name', 'course_id', 'description', 'criteria', 'image_url')
|
||||
|
||||
|
||||
class BadgeAssertionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for the BadgeAssertion model.
|
||||
"""
|
||||
badge_class = BadgeClassSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BadgeAssertion
|
||||
fields = ('badge_class', 'image_url', 'assertion_url', 'created')
|
||||
@@ -1,218 +0,0 @@
|
||||
"""
|
||||
Tests for the badges API views.
|
||||
"""
|
||||
|
||||
|
||||
from ddt import data, ddt, unpack
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.util.testing import UrlResetMixin
|
||||
from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory
|
||||
from openedx.core.lib.api.test_utils import ApiTestCase
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
FEATURES_WITH_BADGES_ENABLED = settings.FEATURES.copy()
|
||||
FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
|
||||
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
|
||||
class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase):
|
||||
"""
|
||||
Mixin for badge API tests.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.user = UserFactory.create()
|
||||
# Password defined by factory.
|
||||
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Return the URL to look up the current user's assertions.
|
||||
"""
|
||||
return f'/api/badges/v1/assertions/user/{self.user.username}/'
|
||||
|
||||
def check_class_structure(self, badge_class, json_class):
|
||||
"""
|
||||
Check a JSON response against a known badge class.
|
||||
"""
|
||||
assert badge_class.issuing_component == json_class['issuing_component']
|
||||
assert badge_class.slug == json_class['slug']
|
||||
assert badge_class.image.url in json_class['image_url']
|
||||
assert badge_class.description == json_class['description']
|
||||
assert badge_class.criteria == json_class['criteria']
|
||||
assert (badge_class.course_id and str(badge_class.course_id)) == json_class['course_id']
|
||||
|
||||
def check_assertion_structure(self, assertion, json_assertion):
|
||||
"""
|
||||
Check a JSON response against a known assertion object.
|
||||
"""
|
||||
assert assertion.image_url == json_assertion['image_url']
|
||||
assert assertion.assertion_url == json_assertion['assertion_url']
|
||||
self.check_class_structure(assertion.badge_class, json_assertion['badge_class'])
|
||||
|
||||
def get_course_id(self, wildcard, badge_class):
|
||||
"""
|
||||
Used for tests which may need to test for a course_id or a wildcard.
|
||||
"""
|
||||
if wildcard:
|
||||
return '*'
|
||||
else:
|
||||
return str(badge_class.course_id)
|
||||
|
||||
def create_badge_class(self, check_course, **kwargs):
|
||||
"""
|
||||
Create a badge class, using a course id if it's relevant to the URL pattern.
|
||||
"""
|
||||
if check_course:
|
||||
return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs)
|
||||
return RandomBadgeClassFactory.create(**kwargs)
|
||||
|
||||
def get_qs_args(self, check_course, wildcard, badge_class):
|
||||
"""
|
||||
Get a dictionary to be serialized into querystring params based on class settings.
|
||||
"""
|
||||
qs_args = {
|
||||
'issuing_component': badge_class.issuing_component,
|
||||
'slug': badge_class.slug,
|
||||
}
|
||||
if check_course:
|
||||
qs_args['course_id'] = self.get_course_id(wildcard, badge_class)
|
||||
return qs_args
|
||||
|
||||
|
||||
class TestUserBadgeAssertions(UserAssertionTestCase):
|
||||
"""
|
||||
Test the general badge assertions retrieval view.
|
||||
"""
|
||||
|
||||
def test_get_assertions(self):
|
||||
"""
|
||||
Verify we can get all of a user's badge assertions.
|
||||
"""
|
||||
for dummy in range(3):
|
||||
BadgeAssertionFactory(user=self.user)
|
||||
# Add in a course scoped badge-- these should not be excluded from the full listing.
|
||||
BadgeAssertionFactory(user=self.user, badge_class=BadgeClassFactory(course_id=self.course.location.course_key))
|
||||
# Should not be included.
|
||||
for dummy in range(3):
|
||||
self.create_badge_class(False)
|
||||
response = self.get_json(self.url())
|
||||
assert len(response['results']) == 4
|
||||
|
||||
def test_assertion_structure(self):
|
||||
badge_class = self.create_badge_class(False)
|
||||
assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
|
||||
response = self.get_json(self.url())
|
||||
self.check_assertion_structure(assertion, response['results'][0])
|
||||
|
||||
|
||||
class TestUserCourseBadgeAssertions(UserAssertionTestCase):
|
||||
"""
|
||||
Test the Badge Assertions view with the course_id filter.
|
||||
"""
|
||||
|
||||
def test_get_assertions(self):
|
||||
"""
|
||||
Verify we can get assertions via the course_id and username.
|
||||
"""
|
||||
course_key = self.course.location.course_key
|
||||
badge_class = BadgeClassFactory.create(course_id=course_key)
|
||||
for dummy in range(3):
|
||||
BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
|
||||
# Should not be included, as they don't share the target badge class.
|
||||
for dummy in range(3):
|
||||
BadgeAssertionFactory.create(user=self.user)
|
||||
# Also should not be included, as they don't share the same user.
|
||||
for dummy in range(6):
|
||||
BadgeAssertionFactory.create(badge_class=badge_class)
|
||||
response = self.get_json(self.url(), data={'course_id': str(course_key)})
|
||||
assert len(response['results']) == 3
|
||||
unused_course = CourseFactory.create()
|
||||
response = self.get_json(self.url(), data={'course_id': str(unused_course.location.course_key)})
|
||||
assert len(response['results']) == 0
|
||||
|
||||
def test_assertion_structure(self):
|
||||
"""
|
||||
Verify the badge assertion structure is as expected when a course is involved.
|
||||
"""
|
||||
course_key = self.course.location.course_key
|
||||
badge_class = BadgeClassFactory.create(course_id=course_key)
|
||||
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
|
||||
response = self.get_json(self.url())
|
||||
self.check_assertion_structure(assertion, response['results'][0])
|
||||
|
||||
|
||||
@ddt
|
||||
class TestUserBadgeAssertionsByClass(UserAssertionTestCase):
|
||||
"""
|
||||
Test the Badge Assertions view with the badge class filter.
|
||||
"""
|
||||
|
||||
@unpack
|
||||
@data((False, False), (True, False), (True, True))
|
||||
def test_get_assertions(self, check_course, wildcard):
|
||||
"""
|
||||
Verify we can get assertions via the badge class and username.
|
||||
"""
|
||||
badge_class = self.create_badge_class(check_course)
|
||||
for dummy in range(3):
|
||||
BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
|
||||
if badge_class.course_id:
|
||||
# Also create a version of this badge under a different course.
|
||||
alt_class = BadgeClassFactory.create(
|
||||
slug=badge_class.slug, issuing_component=badge_class.issuing_component,
|
||||
course_id=CourseFactory.create().location.course_key
|
||||
)
|
||||
BadgeAssertionFactory.create(user=self.user, badge_class=alt_class)
|
||||
# Same badge class, but different user. Should not show up in the list.
|
||||
for dummy in range(5):
|
||||
BadgeAssertionFactory.create(badge_class=badge_class)
|
||||
# Different badge class AND different user. Certainly shouldn't show up in the list!
|
||||
for dummy in range(6):
|
||||
BadgeAssertionFactory.create()
|
||||
|
||||
response = self.get_json(
|
||||
self.url(),
|
||||
data=self.get_qs_args(check_course, wildcard, badge_class),
|
||||
)
|
||||
if wildcard:
|
||||
expected_length = 4
|
||||
else:
|
||||
expected_length = 3
|
||||
assert len(response['results']) == expected_length
|
||||
unused_class = self.create_badge_class(check_course, slug='unused_slug', issuing_component='unused_component')
|
||||
|
||||
response = self.get_json(
|
||||
self.url(),
|
||||
data=self.get_qs_args(check_course, wildcard, unused_class),
|
||||
)
|
||||
assert len(response['results']) == 0
|
||||
|
||||
def check_badge_class_assertion(self, check_course, wildcard, badge_class):
|
||||
"""
|
||||
Given a badge class, create an assertion for the current user and fetch it, checking the structure.
|
||||
"""
|
||||
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
|
||||
response = self.get_json(
|
||||
self.url(),
|
||||
data=self.get_qs_args(check_course, wildcard, badge_class),
|
||||
)
|
||||
self.check_assertion_structure(assertion, response['results'][0])
|
||||
|
||||
@unpack
|
||||
@data((False, False), (True, False), (True, True))
|
||||
def test_assertion_structure(self, check_course, wildcard):
|
||||
self.check_badge_class_assertion(check_course, wildcard, self.create_badge_class(check_course))
|
||||
|
||||
@unpack
|
||||
@data((False, False), (True, False), (True, True))
|
||||
def test_empty_issuing_component(self, check_course, wildcard):
|
||||
self.check_badge_class_assertion(
|
||||
check_course, wildcard, self.create_badge_class(check_course, issuing_component='')
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
URLs for badges API
|
||||
"""
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import re_path
|
||||
|
||||
from .views import UserBadgeAssertions
|
||||
|
||||
urlpatterns = [
|
||||
re_path('^assertions/user/' + settings.USERNAME_PATTERN + '/$',
|
||||
UserBadgeAssertions.as_view(), name='user_assertions'),
|
||||
]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
API views for badges
|
||||
"""
|
||||
|
||||
|
||||
from edx_rest_framework_extensions.auth.session.authentication import \
|
||||
SessionAuthenticationAllowInactiveUser
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import generics
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from lms.djangoapps.badges.models import BadgeAssertion
|
||||
from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory
|
||||
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
|
||||
|
||||
from .serializers import BadgeAssertionSerializer
|
||||
|
||||
|
||||
class InvalidCourseKeyError(APIException):
|
||||
"""
|
||||
Raised the course key given isn't valid.
|
||||
"""
|
||||
status_code = 400
|
||||
default_detail = "The course key provided was invalid."
|
||||
|
||||
|
||||
class UserBadgeAssertions(generics.ListAPIView):
|
||||
"""
|
||||
**Use Cases**
|
||||
|
||||
Request a list of assertions for a user, optionally constrained to a course.
|
||||
|
||||
**Example Requests**
|
||||
|
||||
GET /api/badges/v1/assertions/user/{username}/
|
||||
|
||||
**Response Values**
|
||||
|
||||
Body comprised of a list of objects with the following fields:
|
||||
|
||||
* badge_class: The badge class the assertion was awarded for. Represented as an object
|
||||
with the following fields:
|
||||
* slug: The identifier for the badge class
|
||||
* issuing_component: The software component responsible for issuing this badge.
|
||||
* display_name: The display name of the badge.
|
||||
* course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course.
|
||||
* description: A description of the award and its significance.
|
||||
* criteria: A description of what is needed to obtain this award.
|
||||
* image_url: A URL to the icon image used to represent this award.
|
||||
* image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award
|
||||
in its headers.
|
||||
* assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools
|
||||
and software.
|
||||
|
||||
**Params**
|
||||
|
||||
* slug (optional): The identifier for a particular badge class to filter by.
|
||||
* issuing_component (optional): The issuing component for a particular badge class to filter by
|
||||
(requires slug to have been specified, or this will be ignored.) If slug is provided and this is not,
|
||||
assumes the issuing_component should be empty.
|
||||
* course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is
|
||||
provided, and this field is not specified, assumes that the target badge has an empty course_id field.
|
||||
'*' may be used to get all badges with the specified slug, issuing_component combination across all courses.
|
||||
|
||||
**Returns**
|
||||
|
||||
* 200 on success, with a list of Badge Assertion objects.
|
||||
* 403 if a user who does not have permission to masquerade as
|
||||
another user specifies a username other than their own.
|
||||
* 404 if the specified user does not exist
|
||||
|
||||
{
|
||||
"count": 7,
|
||||
"previous": null,
|
||||
"num_pages": 1,
|
||||
"results": [
|
||||
{
|
||||
"badge_class": {
|
||||
"slug": "special_award",
|
||||
"issuing_component": "openedx__course",
|
||||
"display_name": "Very Special Award",
|
||||
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"description": "Awarded for people who did something incredibly special",
|
||||
"criteria": "Do something incredibly special.",
|
||||
"image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png"
|
||||
},
|
||||
"image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png",
|
||||
"assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
serializer_class = BadgeAssertionSerializer
|
||||
authentication_classes = (
|
||||
BearerAuthenticationAllowInactiveUser,
|
||||
SessionAuthenticationAllowInactiveUser
|
||||
)
|
||||
permission_classes = (is_field_shared_factory("accomplishments_shared"),)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Return most recent to least recent badge.
|
||||
"""
|
||||
return queryset.order_by('-created')
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get all badges for the username specified.
|
||||
"""
|
||||
queryset = BadgeAssertion.objects.filter(user__username=self.kwargs['username'])
|
||||
provided_course_id = self.request.query_params.get('course_id')
|
||||
if provided_course_id == '*':
|
||||
# We might want to get all the matching course scoped badges to see how many courses
|
||||
# a user managed to get a specific award on.
|
||||
course_id = None
|
||||
elif provided_course_id:
|
||||
try:
|
||||
course_id = CourseKey.from_string(provided_course_id)
|
||||
except InvalidKeyError:
|
||||
raise InvalidCourseKeyError # lint-amnesty, pylint: disable=raise-missing-from
|
||||
elif 'slug' not in self.request.query_params:
|
||||
# Need to get all badges for the user.
|
||||
course_id = None
|
||||
else:
|
||||
# Django won't let us use 'None' for querying a ForeignKey field. We have to use this special
|
||||
# 'Empty' value to indicate we're looking only for badges without a course key set.
|
||||
course_id = CourseKeyField.Empty
|
||||
|
||||
if course_id is not None:
|
||||
queryset = queryset.filter(badge_class__course_id=course_id)
|
||||
if self.request.query_params.get('slug'):
|
||||
queryset = queryset.filter(
|
||||
badge_class__slug=self.request.query_params['slug'],
|
||||
badge_class__issuing_component=self.request.query_params.get('issuing_component', '')
|
||||
)
|
||||
return queryset
|
||||
@@ -13,9 +13,3 @@ class BadgesConfig(AppConfig):
|
||||
Application Configuration for Badges.
|
||||
"""
|
||||
name = 'lms.djangoapps.badges'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Connect signal handlers.
|
||||
"""
|
||||
from . import handlers # pylint: disable=unused-import
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"""
|
||||
Badge Awarding backend for Badgr-Server.
|
||||
"""
|
||||
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
import requests
|
||||
from cryptography.fernet import Fernet
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from eventtracking import tracker
|
||||
from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
|
||||
from requests.packages.urllib3.exceptions import HTTPError # lint-amnesty, pylint: disable=import-error
|
||||
|
||||
from edx_django_utils.cache import TieredCache
|
||||
|
||||
from lms.djangoapps.badges.backends.base import BadgeBackend
|
||||
from lms.djangoapps.badges.models import BadgeAssertion
|
||||
|
||||
MAX_SLUG_LENGTH = 255
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BadgrBackend(BadgeBackend):
|
||||
"""
|
||||
Backend for Badgr-Server by Concentric Sky. http://info.badgr.io/
|
||||
"""
|
||||
badges = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
if None in (settings.BADGR_USERNAME,
|
||||
settings.BADGR_PASSWORD,
|
||||
settings.BADGR_TOKENS_CACHE_KEY,
|
||||
settings.BADGR_ISSUER_SLUG,
|
||||
settings.BADGR_BASE_URL):
|
||||
error_msg = (
|
||||
"One or more of the required settings are not defined. "
|
||||
"Required settings: BADGR_USERNAME, BADGR_PASSWORD, "
|
||||
"BADGR_TOKENS_CACHE_KEY, BADGR_ISSUER_SLUG, BADGR_BASE_URL.")
|
||||
LOGGER.error(error_msg)
|
||||
raise ImproperlyConfigured(error_msg)
|
||||
|
||||
@lazy
|
||||
def _base_url(self):
|
||||
"""
|
||||
Base URL for API requests that contain the issuer slug.
|
||||
"""
|
||||
return f"{settings.BADGR_BASE_URL}/v2/issuers/{settings.BADGR_ISSUER_SLUG}"
|
||||
|
||||
@lazy
|
||||
def _badge_create_url(self):
|
||||
"""
|
||||
URL for generating a new Badge specification
|
||||
"""
|
||||
return f"{self._base_url}/badgeclasses"
|
||||
|
||||
def _badge_url(self, slug):
|
||||
"""
|
||||
Get the URL for a course's badge in a given mode.
|
||||
"""
|
||||
return f"{settings.BADGR_BASE_URL}/v2/badgeclasses/{slug}"
|
||||
|
||||
def _assertion_url(self, slug):
|
||||
"""
|
||||
URL for generating a new assertion.
|
||||
"""
|
||||
return f"{self._badge_url(slug)}/assertions"
|
||||
|
||||
def _slugify(self, badge_class):
|
||||
"""
|
||||
Get a compatible badge slug from the specification.
|
||||
"""
|
||||
slug = badge_class.issuing_component + badge_class.slug
|
||||
if badge_class.issuing_component and badge_class.course_id:
|
||||
# Make this unique to the course, and down to 64 characters.
|
||||
# We don't do this to badges without issuing_component set for backwards compatibility.
|
||||
slug = hashlib.sha256((slug + str(badge_class.course_id)).encode('utf-8')).hexdigest()
|
||||
if len(slug) > MAX_SLUG_LENGTH:
|
||||
# Will be 64 characters.
|
||||
slug = hashlib.sha256(slug).hexdigest()
|
||||
return slug
|
||||
|
||||
def _log_if_raised(self, response, data):
|
||||
"""
|
||||
Log server response if there was an error.
|
||||
"""
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except HTTPError:
|
||||
LOGGER.error(
|
||||
"Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n"
|
||||
"and data values %r\n"
|
||||
"Response status was %s.\n%s",
|
||||
response.request.url, response.request.headers,
|
||||
data,
|
||||
response.status_code, response.content
|
||||
)
|
||||
raise
|
||||
|
||||
def _create_badge(self, badge_class):
|
||||
"""
|
||||
Create the badge class on Badgr.
|
||||
"""
|
||||
image = badge_class.image
|
||||
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
|
||||
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
|
||||
content_type, __ = mimetypes.guess_type(image.name)
|
||||
if not content_type:
|
||||
raise ValueError(
|
||||
"Could not determine content-type of image! Make sure it is a properly named .png file. "
|
||||
"Filename was: {}".format(image.name)
|
||||
)
|
||||
with open(image.path, 'rb') as image_file:
|
||||
files = {'image': (image.name, image_file, content_type)}
|
||||
data = {
|
||||
'name': badge_class.display_name,
|
||||
'criteriaUrl': badge_class.criteria,
|
||||
'description': badge_class.description,
|
||||
}
|
||||
result = requests.post(
|
||||
self._badge_create_url, headers=self._get_headers(),
|
||||
data=data, files=files, timeout=settings.BADGR_TIMEOUT)
|
||||
self._log_if_raised(result, data)
|
||||
try:
|
||||
result_json = result.json()
|
||||
badgr_badge_class = result_json['result'][0]
|
||||
badgr_server_slug = badgr_badge_class.get('entityId')
|
||||
badge_class.badgr_server_slug = badgr_server_slug
|
||||
badge_class.save()
|
||||
except Exception as excep: # pylint: disable=broad-except
|
||||
LOGGER.error(
|
||||
'Error on saving Badgr Server Slug of badge_class slug '
|
||||
'"{0}" with response json "{1}" : {2}'.format(
|
||||
badge_class.slug, result.json(), excep))
|
||||
|
||||
def _send_assertion_created_event(self, user, assertion):
|
||||
"""
|
||||
Send an analytics event to record the creation of a badge assertion.
|
||||
"""
|
||||
tracker.emit(
|
||||
'edx.badge.assertion.created', {
|
||||
'user_id': user.id,
|
||||
'badge_slug': assertion.badge_class.slug,
|
||||
'badge_badgr_server_slug': assertion.badge_class.badgr_server_slug,
|
||||
'badge_name': assertion.badge_class.display_name,
|
||||
'issuing_component': assertion.badge_class.issuing_component,
|
||||
'course_id': str(assertion.badge_class.course_id),
|
||||
'enrollment_mode': assertion.badge_class.mode,
|
||||
'assertion_id': assertion.id,
|
||||
'assertion_image_url': assertion.image_url,
|
||||
'assertion_json_url': assertion.assertion_url,
|
||||
'issuer': assertion.data.get('issuer'),
|
||||
}
|
||||
)
|
||||
|
||||
def _create_assertion(self, badge_class, user, evidence_url):
|
||||
"""
|
||||
Register an assertion with the Badgr server for a particular user for a specific class.
|
||||
"""
|
||||
data = {
|
||||
"recipient": {
|
||||
"identity": user.email,
|
||||
"type": "email"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"url": evidence_url
|
||||
}
|
||||
],
|
||||
"notify": settings.BADGR_ENABLE_NOTIFICATIONS,
|
||||
}
|
||||
response = requests.post(
|
||||
self._assertion_url(badge_class.badgr_server_slug),
|
||||
headers=self._get_headers(),
|
||||
json=data,
|
||||
timeout=settings.BADGR_TIMEOUT
|
||||
)
|
||||
self._log_if_raised(response, data)
|
||||
assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class)
|
||||
try:
|
||||
response_json = response.json()
|
||||
assertion.data = response_json['result'][0]
|
||||
assertion.image_url = assertion.data['image']
|
||||
assertion.assertion_url = assertion.data['openBadgeId']
|
||||
assertion.backend = 'BadgrBackend'
|
||||
assertion.save()
|
||||
self._send_assertion_created_event(user, assertion)
|
||||
return assertion
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.error(
|
||||
'Error saving BadgeAssertion for user: "{0}" '
|
||||
'with response from server: {1};'
|
||||
'Encountered exception: {2}'.format(
|
||||
user.email, response.text, exc))
|
||||
|
||||
@staticmethod
|
||||
def _fernet_setup():
|
||||
"""
|
||||
Set up the Fernet class for encrypting/decrypting tokens.
|
||||
Fernet keys must always be URL-safe base64 encoded 32-byte binary
|
||||
strings. Use the SECRET_KEY for creating the encryption key.
|
||||
"""
|
||||
fernet_key = base64.urlsafe_b64encode(
|
||||
settings.SECRET_KEY.ljust(64).encode('utf-8')[:32]
|
||||
)
|
||||
return Fernet(fernet_key)
|
||||
|
||||
def _encrypt_token(self, token):
|
||||
"""
|
||||
Encrypt a token
|
||||
"""
|
||||
fernet = self._fernet_setup()
|
||||
return fernet.encrypt(token.encode('utf-8'))
|
||||
|
||||
def _decrypt_token(self, token):
|
||||
"""
|
||||
Decrypt a token
|
||||
"""
|
||||
fernet = self._fernet_setup()
|
||||
return fernet.decrypt(token).decode()
|
||||
|
||||
def _get_and_cache_oauth_tokens(self, refresh_token=None):
|
||||
"""
|
||||
Get or renew OAuth tokens. If a refresh_token is provided,
|
||||
use it to renew tokens, otherwise create new ones.
|
||||
Once tokens are created/renewed, encrypt the values and cache them.
|
||||
"""
|
||||
data = {
|
||||
'username': settings.BADGR_USERNAME,
|
||||
'password': settings.BADGR_PASSWORD,
|
||||
}
|
||||
if refresh_token:
|
||||
data = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
|
||||
oauth_url = "{}/o/token".format(settings.BADGR_BASE_URL)
|
||||
|
||||
response = requests.post(
|
||||
oauth_url, data=data, timeout=settings.BADGR_TIMEOUT
|
||||
)
|
||||
self._log_if_raised(response, data)
|
||||
try:
|
||||
data = response.json()
|
||||
result = {
|
||||
'access_token': self._encrypt_token(data['access_token']),
|
||||
'refresh_token': self._encrypt_token(data['refresh_token']),
|
||||
'expires_at': datetime.datetime.utcnow() + datetime.timedelta(
|
||||
seconds=data['expires_in'])
|
||||
}
|
||||
# The refresh_token is long-lived, we want to be able to retrieve
|
||||
# it from cache as long as possible.
|
||||
# Set the cache timeout to None so the cache key never expires
|
||||
# (https://docs.djangoproject.com/en/2.2/topics/cache/#cache-arguments)
|
||||
TieredCache.set_all_tiers(
|
||||
settings.BADGR_TOKENS_CACHE_KEY, result, None)
|
||||
return result
|
||||
except (KeyError, json.decoder.JSONDecodeError) as json_error:
|
||||
raise requests.RequestException(response=response) from json_error
|
||||
|
||||
def _get_access_token(self):
|
||||
"""
|
||||
Get an access token from cache if one is present and valid. If a
|
||||
token is cached but expired, renew it. If all fails or a token has
|
||||
not yet been cached, create a new one.
|
||||
"""
|
||||
tokens = {}
|
||||
cached_response = TieredCache.get_cached_response(
|
||||
settings.BADGR_TOKENS_CACHE_KEY)
|
||||
if cached_response.is_found:
|
||||
cached_tokens = cached_response.value
|
||||
# add a 5 seconds buffer to the cutoff timestamp to make sure
|
||||
# the token will not expire while in use
|
||||
expiry_cutoff = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(seconds=5))
|
||||
if cached_tokens.get('expires_at') > expiry_cutoff:
|
||||
tokens = cached_tokens
|
||||
else:
|
||||
# renew the tokens with the cached `refresh_token`
|
||||
refresh_token = self._decrypt_token(cached_tokens.get(
|
||||
'refresh_token'))
|
||||
tokens = self._get_and_cache_oauth_tokens(
|
||||
refresh_token=refresh_token)
|
||||
|
||||
# if no tokens are cached or something went wrong with
|
||||
# retreiving/renewing them, go and create new tokens
|
||||
if not tokens:
|
||||
tokens = self._get_and_cache_oauth_tokens()
|
||||
return self._decrypt_token(tokens.get('access_token'))
|
||||
|
||||
def _get_headers(self):
|
||||
"""
|
||||
Headers to send along with the request-- used for authentication.
|
||||
"""
|
||||
access_token = self._get_access_token()
|
||||
return {'Authorization': 'Bearer {}'.format(access_token)}
|
||||
|
||||
def _ensure_badge_created(self, badge_class):
|
||||
"""
|
||||
Verify a badge has been created for this badge class, and create it if not.
|
||||
"""
|
||||
slug = badge_class.badgr_server_slug
|
||||
if slug in BadgrBackend.badges:
|
||||
return
|
||||
response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
|
||||
if response.status_code != 200:
|
||||
self._create_badge(badge_class)
|
||||
BadgrBackend.badges.append(slug)
|
||||
|
||||
def award(self, badge_class, user, evidence_url=None):
|
||||
"""
|
||||
Make sure the badge class has been created on the backend, and then award the badge class to the user.
|
||||
"""
|
||||
self._ensure_badge_created(badge_class)
|
||||
return self._create_assertion(badge_class, user, evidence_url)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Base class for badge backends.
|
||||
"""
|
||||
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
|
||||
class BadgeBackend(metaclass=ABCMeta):
|
||||
"""
|
||||
Defines the interface for badging backends.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def award(self, badge_class, user, evidence_url=None):
|
||||
"""
|
||||
Create a badge assertion for the user using this backend.
|
||||
"""
|
||||
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
Dummy backend, for use in testing.
|
||||
"""
|
||||
|
||||
|
||||
from lms.djangoapps.badges.backends.base import BadgeBackend
|
||||
from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory
|
||||
|
||||
|
||||
class DummyBackend(BadgeBackend):
|
||||
"""
|
||||
Dummy backend that creates assertions without contacting any real-world backend.
|
||||
"""
|
||||
def award(self, badge_class, user, evidence_url=None):
|
||||
return BadgeAssertionFactory(badge_class=badge_class, user=user)
|
||||
@@ -1,301 +0,0 @@
|
||||
"""
|
||||
Tests for BadgrBackend
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
import json
|
||||
import ddt
|
||||
import httpretty
|
||||
from django.test.utils import override_settings
|
||||
from lazy.lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
|
||||
|
||||
from edx_django_utils.cache import TieredCache
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from common.djangoapps.track.tests import FROZEN_TIME, EventTrackingTestCase
|
||||
from lms.djangoapps.badges.backends.badgr import BadgrBackend
|
||||
from lms.djangoapps.badges.models import BadgeAssertion
|
||||
from lms.djangoapps.badges.tests.factories import BadgeClassFactory
|
||||
from openedx.core.lib.tests.assertions.events import assert_event_matches
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
BADGR_SETTINGS = {
|
||||
'BADGR_BASE_URL': 'https://example.com',
|
||||
'BADGR_ISSUER_SLUG': 'test-issuer',
|
||||
'BADGR_USERNAME': 'example@example.com',
|
||||
'BADGR_PASSWORD': 'password',
|
||||
'BADGR_TOKENS_CACHE_KEY': 'badgr-test-cache-key'
|
||||
}
|
||||
|
||||
# Should be the hashed result of test_slug as the slug, and test_component as the component
|
||||
EXAMPLE_SLUG = '9e915d55bb304a73d20c453531d3c27f81574218413c23903823d20d11b587ae'
|
||||
BADGR_SERVER_SLUG = 'test_badgr_server_slug'
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@ddt.ddt
|
||||
@override_settings(**BADGR_SETTINGS)
|
||||
@httpretty.activate
|
||||
class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
"""
|
||||
Tests the BadgeHandler object
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a course and user to test with.
|
||||
"""
|
||||
super().setUp()
|
||||
# Need key to be deterministic to test slugs.
|
||||
self.course = CourseFactory.create(
|
||||
org='edX', course='course_test', run='test_run', display_name='Badged',
|
||||
start=datetime.datetime(year=2015, month=5, day=19),
|
||||
end=datetime.datetime(year=2015, month=5, day=20)
|
||||
)
|
||||
self.user = UserFactory.create(email='example@example.com')
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.location.course_key, mode='honor')
|
||||
# Need to empty this on each run.
|
||||
BadgrBackend.badges = []
|
||||
self.badge_class = BadgeClassFactory.create(course_id=self.course.location.course_key)
|
||||
self.legacy_badge_class = BadgeClassFactory.create(
|
||||
course_id=self.course.location.course_key, issuing_component=''
|
||||
)
|
||||
self.no_course_badge_class = BadgeClassFactory.create()
|
||||
TieredCache.dangerous_clear_all_tiers()
|
||||
httpretty.httpretty.reset()
|
||||
|
||||
@lazy
|
||||
def handler(self):
|
||||
"""
|
||||
Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings
|
||||
overrides aren't in place.
|
||||
"""
|
||||
return BadgrBackend()
|
||||
|
||||
def _mock_badgr_tokens_api(self, result):
|
||||
assert httpretty.is_enabled()
|
||||
responses = [httpretty.Response(body=json.dumps(result),
|
||||
content_type='application/json')]
|
||||
httpretty.register_uri(httpretty.POST,
|
||||
'https://example.com/o/token',
|
||||
responses=responses)
|
||||
|
||||
def test_urls(self):
|
||||
"""
|
||||
Make sure the handler generates the correct URLs for different API tasks.
|
||||
"""
|
||||
assert self.handler._base_url == 'https://example.com/v2/issuers/test-issuer'
|
||||
# lint-amnesty, pylint: disable=no-member
|
||||
assert self.handler._badge_create_url == 'https://example.com/v2/issuers/test-issuer/badgeclasses'
|
||||
# lint-amnesty, pylint: disable=no-member
|
||||
assert self.handler._badge_url('test_slug_here') ==\
|
||||
'https://example.com/v2/badgeclasses/test_slug_here'
|
||||
assert self.handler._assertion_url('another_test_slug') ==\
|
||||
'https://example.com/v2/badgeclasses/another_test_slug/assertions'
|
||||
|
||||
def check_headers(self, headers):
|
||||
"""
|
||||
Verify the a headers dict from a requests call matches the proper auth info.
|
||||
"""
|
||||
assert headers == {'Authorization': 'Bearer 12345'}
|
||||
|
||||
def test_get_headers(self):
|
||||
"""
|
||||
Check to make sure the handler generates appropriate HTTP headers.
|
||||
"""
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.check_headers(self.handler._get_headers()) # lint-amnesty, pylint: disable=no-member
|
||||
|
||||
@patch('requests.post')
|
||||
def test_create_badge(self, post):
|
||||
"""
|
||||
Verify badge spec creation works.
|
||||
"""
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
with self.allow_transaction_exception():
|
||||
self.handler._create_badge(self.badge_class)
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == 'https://example.com/v2/issuers/test-issuer/badgeclasses'
|
||||
assert kwargs['files']['image'][0] == self.badge_class.image.name
|
||||
assert kwargs['files']['image'][2] == 'image/png'
|
||||
self.check_headers(kwargs['headers'])
|
||||
assert kwargs['data'] ==\
|
||||
{'name': 'Test Badge',
|
||||
'criteriaUrl': 'https://example.com/syllabus',
|
||||
'description': "Yay! It's a test badge."}
|
||||
|
||||
def test_ensure_badge_created_cache(self):
|
||||
"""
|
||||
Make sure ensure_badge_created doesn't call create_badge if we know the badge is already there.
|
||||
"""
|
||||
BadgrBackend.badges.append(BADGR_SERVER_SLUG)
|
||||
self.handler._create_badge = Mock()
|
||||
self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
|
||||
assert not self.handler._create_badge.called
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
('badge_class', EXAMPLE_SLUG),
|
||||
('legacy_badge_class', 'test_slug'),
|
||||
('no_course_badge_class', 'test_componenttest_slug')
|
||||
)
|
||||
def test_slugs(self, badge_class_type, slug):
|
||||
assert self.handler._slugify(getattr(self, badge_class_type)) == slug
|
||||
# lint-amnesty, pylint: disable=no-member
|
||||
|
||||
@patch('requests.get')
|
||||
def test_ensure_badge_created_checks(self, get):
|
||||
response = Mock()
|
||||
response.status_code = 200
|
||||
get.return_value = response
|
||||
assert 'test_componenttest_slug' not in BadgrBackend.badges
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_badge = Mock()
|
||||
self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
|
||||
assert get.called
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == (
|
||||
'https://example.com/v2/badgeclasses/' +
|
||||
BADGR_SERVER_SLUG)
|
||||
self.check_headers(kwargs['headers'])
|
||||
assert BADGR_SERVER_SLUG in BadgrBackend.badges
|
||||
assert not self.handler._create_badge.called
|
||||
|
||||
@patch('requests.get')
|
||||
def test_ensure_badge_created_creates(self, get):
|
||||
response = Mock()
|
||||
response.status_code = 404
|
||||
get.return_value = response
|
||||
assert BADGR_SERVER_SLUG not in BadgrBackend.badges
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_badge = Mock()
|
||||
self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
|
||||
assert self.handler._create_badge.called
|
||||
assert self.handler._create_badge.call_args == call(self.badge_class)
|
||||
assert BADGR_SERVER_SLUG in BadgrBackend.badges
|
||||
|
||||
@patch('requests.post')
|
||||
def test_badge_creation_event(self, post):
|
||||
result = {
|
||||
'result': [{
|
||||
'openBadgeId': 'http://www.example.com/example',
|
||||
'image': 'http://www.example.com/example.png',
|
||||
'issuer': 'https://example.com/v2/issuers/test-issuer'
|
||||
}]
|
||||
}
|
||||
response = Mock()
|
||||
response.json.return_value = result
|
||||
post.return_value = response
|
||||
self.recreate_tracker()
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_assertion(self.badge_class, self.user, 'https://example.com/irrefutable_proof') # lint-amnesty, pylint: disable=no-member
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == ((
|
||||
'https://example.com/v2/badgeclasses/' +
|
||||
BADGR_SERVER_SLUG) +
|
||||
'/assertions')
|
||||
self.check_headers(kwargs['headers'])
|
||||
assertion = BadgeAssertion.objects.get(user=self.user, badge_class__course_id=self.course.location.course_key)
|
||||
assert assertion.data == result['result'][0]
|
||||
assert assertion.image_url == 'http://www.example.com/example.png'
|
||||
assert assertion.assertion_url == 'http://www.example.com/example'
|
||||
assert kwargs['json'] == {"recipient": {"identity": 'example@example.com', "type": "email"},
|
||||
"evidence": [{"url": 'https://example.com/irrefutable_proof'}],
|
||||
"notify": False}
|
||||
assert_event_matches({
|
||||
'name': 'edx.badge.assertion.created',
|
||||
'data': {
|
||||
'user_id': self.user.id,
|
||||
'course_id': str(self.course.location.course_key),
|
||||
'enrollment_mode': 'honor',
|
||||
'assertion_id': assertion.id,
|
||||
'badge_name': 'Test Badge',
|
||||
'badge_slug': 'test_slug',
|
||||
'badge_badgr_server_slug': BADGR_SERVER_SLUG,
|
||||
'issuing_component': 'test_component',
|
||||
'assertion_image_url': 'http://www.example.com/example.png',
|
||||
'assertion_json_url': 'http://www.example.com/example',
|
||||
'issuer': 'https://example.com/v2/issuers/test-issuer',
|
||||
},
|
||||
'context': {},
|
||||
'timestamp': FROZEN_TIME
|
||||
}, self.get_event())
|
||||
|
||||
def test_get_new_tokens(self):
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
self.handler._get_and_cache_oauth_tokens()
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'username': ['example@example.com'],
|
||||
'password': ['password']}
|
||||
|
||||
def test_renew_tokens(self):
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
self.handler._get_and_cache_oauth_tokens(refresh_token='67890')
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'grant_type': ['refresh_token'],
|
||||
'refresh_token': ['67890']}
|
||||
|
||||
def test_get_access_token_from_cache_valid(self):
|
||||
encrypted_access_token = self.handler._encrypt_token('12345')
|
||||
encrypted_refresh_token = self.handler._encrypt_token('67890')
|
||||
tokens = {
|
||||
'access_token': encrypted_access_token,
|
||||
'refresh_token': encrypted_refresh_token,
|
||||
'expires_at': datetime.datetime.utcnow() + datetime.timedelta(seconds=20)
|
||||
}
|
||||
TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None)
|
||||
|
||||
access_token = self.handler._get_access_token()
|
||||
assert access_token == self.handler._decrypt_token(
|
||||
tokens.get('access_token'))
|
||||
|
||||
def test_get_access_token_from_cache_expired(self):
|
||||
encrypted_access_token = self.handler._encrypt_token('12345')
|
||||
encrypted_refresh_token = self.handler._encrypt_token('67890')
|
||||
tokens = {
|
||||
'access_token': encrypted_access_token,
|
||||
'refresh_token': encrypted_refresh_token,
|
||||
'expires_at': datetime.datetime.utcnow()
|
||||
}
|
||||
TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None)
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
access_token = self.handler._get_access_token()
|
||||
assert access_token == result.get('access_token')
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'grant_type': ['refresh_token'],
|
||||
'refresh_token': [self.handler._decrypt_token(
|
||||
tokens.get('refresh_token'))]}
|
||||
|
||||
def test_get_access_token_from_cache_none(self):
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
access_token = self.handler._get_access_token()
|
||||
assert access_token == result.get('access_token')
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'username': ['example@example.com'],
|
||||
'password': ['password']}
|
||||
@@ -6,14 +6,9 @@ Helper functions for the course complete event that was originally included with
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass, CourseCompleteImageConfiguration
|
||||
from lms.djangoapps.badges.utils import requires_badges_enabled, site_prefix
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -25,9 +20,7 @@ def course_slug(course_key, mode):
|
||||
"""
|
||||
Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge
|
||||
type, awarded on course completion.
|
||||
|
||||
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
|
||||
|
||||
Badgr's max slug length is 255.
|
||||
"""
|
||||
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
|
||||
@@ -65,65 +58,4 @@ def evidence_url(user_id, course_key):
|
||||
Generates a URL to the user's Certificate HTML view, along with a GET variable that will signal the evidence visit
|
||||
event.
|
||||
"""
|
||||
course_id = str(course_key)
|
||||
# avoid circular import problems
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
cert = GeneratedCertificate.eligible_certificates.get(user__id=int(user_id), course_id=course_id)
|
||||
return site_prefix() + reverse(
|
||||
'certificates:render_cert_by_uuid', kwargs={'certificate_uuid': cert.verify_uuid}) + '?evidence_visit=1'
|
||||
|
||||
|
||||
def criteria(course_key):
|
||||
"""
|
||||
Constructs the 'criteria' URL from the course about page.
|
||||
"""
|
||||
about_path = reverse('about_course', kwargs={'course_id': str(course_key)})
|
||||
return f'{site_prefix()}{about_path}'
|
||||
|
||||
|
||||
def get_completion_badge(course_id, user):
|
||||
"""
|
||||
Given a course key and a user, find the user's enrollment mode
|
||||
and get the Course Completion badge.
|
||||
"""
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
badge_classes = CourseEnrollment.objects.filter(
|
||||
user=user, course_id=course_id
|
||||
).order_by('-is_active')
|
||||
if not badge_classes:
|
||||
return None
|
||||
mode = badge_classes[0].mode
|
||||
course = modulestore().get_course(course_id)
|
||||
if not course.issue_badges:
|
||||
return None
|
||||
return BadgeClass.get_badge_class(
|
||||
slug=course_slug(course_id, mode),
|
||||
issuing_component='',
|
||||
criteria=criteria(course_id),
|
||||
description=badge_description(course, mode),
|
||||
course_id=course_id,
|
||||
mode=mode,
|
||||
display_name=course.display_name,
|
||||
image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode)
|
||||
)
|
||||
|
||||
|
||||
@requires_badges_enabled
|
||||
def course_badge_check(user, course_key):
|
||||
"""
|
||||
Takes a GeneratedCertificate instance, and checks to see if a badge exists for this course, creating
|
||||
it if not, should conditions be right.
|
||||
"""
|
||||
if not modulestore().get_course(course_key).issue_badges:
|
||||
LOGGER.info("Course is not configured to issue badges.")
|
||||
return
|
||||
badge_class = get_completion_badge(course_key, user)
|
||||
if not badge_class:
|
||||
# We're not configured to make a badge for this course mode.
|
||||
return
|
||||
if BadgeAssertion.objects.filter(user=user, badge_class=badge_class):
|
||||
LOGGER.info("Completion badge already exists for this user on this course.")
|
||||
# Badge already exists. Skip.
|
||||
return
|
||||
evidence = evidence_url(user.id, course_key)
|
||||
badge_class.award(user, evidence_url=evidence)
|
||||
return
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Events which have to do with a user doing something with more than one course, such
|
||||
as enrolling in a certain number, completing a certain number, or completing a specific set of courses.
|
||||
"""
|
||||
|
||||
|
||||
from lms.djangoapps.badges.models import BadgeClass, CourseEventBadgesConfiguration
|
||||
from lms.djangoapps.badges.utils import requires_badges_enabled
|
||||
|
||||
|
||||
def award_badge(config, count, user):
|
||||
"""
|
||||
Given one of the configurations for enrollments or completions, award
|
||||
the appropriate badge if one is configured.
|
||||
|
||||
config is a dictionary with integer keys and course keys as values.
|
||||
count is the key to retrieve from this dictionary.
|
||||
user is the user to award the badge to.
|
||||
|
||||
Example config:
|
||||
{3: 'slug_for_badge_for_three_enrollments', 5: 'slug_for_badge_with_five_enrollments'}
|
||||
"""
|
||||
slug = config.get(count)
|
||||
if not slug:
|
||||
return
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug=slug, issuing_component='openedx__course', create=False,
|
||||
)
|
||||
if not badge_class:
|
||||
return
|
||||
if not badge_class.get_for_user(user):
|
||||
badge_class.award(user)
|
||||
|
||||
|
||||
def award_enrollment_badge(user):
|
||||
"""
|
||||
Awards badges based on the number of courses a user is enrolled in.
|
||||
"""
|
||||
config = CourseEventBadgesConfiguration.current().enrolled_settings
|
||||
enrollments = user.courseenrollment_set.filter(is_active=True).count()
|
||||
award_badge(config, enrollments, user)
|
||||
|
||||
|
||||
@requires_badges_enabled
|
||||
def completion_check(user):
|
||||
"""
|
||||
Awards badges based upon the number of courses a user has 'completed'.
|
||||
Courses are never truly complete, but they can be closed.
|
||||
|
||||
For this reason we use checks on certificates to find out if a user has
|
||||
completed courses. This badge will not work if certificate generation isn't
|
||||
enabled and run.
|
||||
"""
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
config = CourseEventBadgesConfiguration.current().completed_settings
|
||||
certificates = user.generatedcertificate_set.filter(status__in=CertificateStatuses.PASSED_STATUSES).count()
|
||||
award_badge(config, certificates, user)
|
||||
|
||||
|
||||
@requires_badges_enabled
|
||||
def course_group_check(user, course_key):
|
||||
"""
|
||||
Awards a badge if a user has completed every course in a defined set.
|
||||
"""
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
config = CourseEventBadgesConfiguration.current().course_group_settings
|
||||
awards = []
|
||||
for slug, keys in config.items():
|
||||
if course_key in keys:
|
||||
certs = user.generatedcertificate_set.filter(
|
||||
status__in=CertificateStatuses.PASSED_STATUSES,
|
||||
course_id__in=keys,
|
||||
)
|
||||
if len(certs) == len(keys):
|
||||
awards.append(slug)
|
||||
|
||||
for slug in awards:
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug=slug, issuing_component='openedx__course', create=False,
|
||||
)
|
||||
if badge_class and not badge_class.get_for_user(user):
|
||||
badge_class.award(user)
|
||||
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
Tests for the course completion helper functions.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.badges.events import course_complete
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
|
||||
class CourseCompleteTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the course completion helper functions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Need key to be deterministic to test slugs.
|
||||
self.course = CourseFactory.create(
|
||||
org='edX', course='course_test', run='test_run', display_name='Badged',
|
||||
start=datetime(year=2015, month=5, day=19),
|
||||
end=datetime(year=2015, month=5, day=20)
|
||||
)
|
||||
self.course_key = self.course.location.course_key
|
||||
|
||||
def test_slug(self):
|
||||
"""
|
||||
Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause
|
||||
the handler to lose track of all badges it made in the past.
|
||||
"""
|
||||
assert course_complete.course_slug(self.course_key, 'honor') ==\
|
||||
'course-v1edxcourse_testtest_run_honor_2055051'
|
||||
assert course_complete.course_slug(self.course_key, 'verified') ==\
|
||||
'course-v1edxcourse_testtest_run_verified_d550ad7'
|
||||
|
||||
def test_dated_description(self):
|
||||
"""
|
||||
Verify that a course with start/end dates contains a description with them.
|
||||
"""
|
||||
assert course_complete.badge_description(self.course, 'honor') ==\
|
||||
'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)'
|
||||
|
||||
def test_self_paced_description(self):
|
||||
"""
|
||||
Verify that a badge created for a course with no end date gets a different description.
|
||||
"""
|
||||
self.course.end = None
|
||||
assert course_complete.badge_description(self.course, 'honor') == 'Completed the course "Badged" (honor)'
|
||||
|
||||
def test_evidence_url(self):
|
||||
"""
|
||||
Make sure the evidence URL points to the right place.
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
cert = GeneratedCertificate.eligible_certificates.create(
|
||||
user=user,
|
||||
course_id=self.course_key,
|
||||
download_uuid=uuid4(),
|
||||
grade="0.95",
|
||||
key='the_key',
|
||||
distinction=True,
|
||||
status='downloadable',
|
||||
mode='honor',
|
||||
name=user.profile.name,
|
||||
verify_uuid=uuid4().hex
|
||||
)
|
||||
assert f'https://edx.org/certificates/{cert.verify_uuid}?evidence_visit=1' ==\
|
||||
course_complete.evidence_url(user.id, self.course_key)
|
||||
@@ -1,184 +0,0 @@
|
||||
"""
|
||||
Tests the course meta badging events
|
||||
"""
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from ddt import data, ddt, unpack
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.badges.tests.factories import CourseEventBadgesConfigurationFactory, RandomBadgeClassFactory
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
|
||||
@ddt
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
|
||||
class CourseEnrollmentBadgeTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the event which awards badges based on number of courses a user is enrolled in.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.badge_classes = [
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
]
|
||||
nums = ['3', '5', '8']
|
||||
entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])]
|
||||
enrollment_config = '\r'.join(entries)
|
||||
self.config = CourseEventBadgesConfigurationFactory(courses_enrolled=enrollment_config)
|
||||
|
||||
def test_no_match(self):
|
||||
"""
|
||||
Make sure a badge isn't created before a user's reached any checkpoint.
|
||||
"""
|
||||
user = UserFactory()
|
||||
course = CourseFactory()
|
||||
CourseEnrollment.enroll(user, course_key=course.location.course_key)
|
||||
assert not user.badgeassertion_set.all()
|
||||
|
||||
@unpack
|
||||
@data((1, 3), (2, 5), (3, 8))
|
||||
def test_checkpoint_matches(self, checkpoint, required_badges):
|
||||
"""
|
||||
Make sure the proper badges are awarded at the right checkpoints.
|
||||
"""
|
||||
user = UserFactory()
|
||||
courses = [CourseFactory() for _i in range(required_badges)]
|
||||
for course in courses:
|
||||
CourseEnrollment.enroll(user, course_key=course.location.course_key)
|
||||
assertions = user.badgeassertion_set.all().order_by('id')
|
||||
assert user.badgeassertion_set.all().count() == checkpoint
|
||||
assert assertions[(checkpoint - 1)].badge_class == self.badge_classes[(checkpoint - 1)]
|
||||
|
||||
|
||||
@ddt
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
|
||||
class CourseCompletionBadgeTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the event which awards badges based on the number of courses completed.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.badge_classes = [
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
]
|
||||
nums = ['2', '6', '9']
|
||||
entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])]
|
||||
completed_config = '\r'.join(entries)
|
||||
self.config = CourseEventBadgesConfigurationFactory.create(courses_completed=completed_config)
|
||||
self.config.clean_fields()
|
||||
|
||||
def test_no_match(self):
|
||||
"""
|
||||
Make sure a badge isn't created before a user's reached any checkpoint.
|
||||
"""
|
||||
user = UserFactory()
|
||||
course = CourseFactory()
|
||||
GeneratedCertificate(
|
||||
user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
|
||||
).save()
|
||||
assert not user.badgeassertion_set.all()
|
||||
|
||||
@unpack
|
||||
@data((1, 2), (2, 6), (3, 9))
|
||||
def test_checkpoint_matches(self, checkpoint, required_badges):
|
||||
"""
|
||||
Make sure the proper badges are awarded at the right checkpoints.
|
||||
"""
|
||||
user = UserFactory()
|
||||
courses = [CourseFactory() for _i in range(required_badges)]
|
||||
for course in courses:
|
||||
GeneratedCertificate(
|
||||
user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
|
||||
).save()
|
||||
assertions = user.badgeassertion_set.all().order_by('id')
|
||||
assert user.badgeassertion_set.all().count() == checkpoint
|
||||
assert assertions[(checkpoint - 1)].badge_class == self.badge_classes[(checkpoint - 1)]
|
||||
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
|
||||
class CourseGroupBadgeTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the event which awards badges when a user completes a set of courses.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.badge_classes = [
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
RandomBadgeClassFactory(
|
||||
issuing_component='openedx__course'
|
||||
),
|
||||
]
|
||||
self.courses = []
|
||||
for _badge_class in self.badge_classes:
|
||||
self.courses.append([CourseFactory().location.course_key for _i in range(3)]) # lint-amnesty, pylint: disable=no-member
|
||||
lines = [badge_class.slug + ',' + ','.join([str(course_key) for course_key in keys])
|
||||
for badge_class, keys in zip(self.badge_classes, self.courses)]
|
||||
config = '\r'.join(lines)
|
||||
self.config = CourseEventBadgesConfigurationFactory(course_groups=config)
|
||||
self.config_map = dict(list(zip(self.badge_classes, self.courses)))
|
||||
|
||||
def test_no_match(self):
|
||||
"""
|
||||
Make sure a badge isn't created before a user's completed any course groups.
|
||||
"""
|
||||
user = UserFactory()
|
||||
course = CourseFactory()
|
||||
GeneratedCertificate(
|
||||
user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
|
||||
).save()
|
||||
assert not user.badgeassertion_set.all()
|
||||
|
||||
def test_group_matches(self):
|
||||
"""
|
||||
Make sure the proper badges are awarded when groups are completed.
|
||||
"""
|
||||
user = UserFactory()
|
||||
items = list(self.config_map.items())
|
||||
for badge_class, course_keys in items:
|
||||
for i, key in enumerate(course_keys):
|
||||
GeneratedCertificate(
|
||||
user=user, course_id=key, status=CertificateStatuses.downloadable
|
||||
).save()
|
||||
# We don't award badges until all three are set.
|
||||
if i + 1 == len(course_keys):
|
||||
assert badge_class.get_for_user(user)
|
||||
else:
|
||||
assert not badge_class.get_for_user(user)
|
||||
classes = [badge.badge_class.id for badge in user.badgeassertion_set.all()]
|
||||
source_classes = [badge.id for badge in self.badge_classes]
|
||||
assert classes == source_classes
|
||||
@@ -1,20 +0,0 @@
|
||||
"""
|
||||
Badges related signal handlers.
|
||||
"""
|
||||
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from common.djangoapps.student.models import EnrollStatusChange
|
||||
from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE
|
||||
from lms.djangoapps.badges.events.course_meta import award_enrollment_badge
|
||||
from lms.djangoapps.badges.utils import badges_enabled
|
||||
|
||||
|
||||
@receiver(ENROLL_STATUS_CHANGE)
|
||||
def award_badge_on_enrollment(sender, event=None, user=None, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Awards enrollment badge to the given user on new enrollments.
|
||||
"""
|
||||
if badges_enabled and event == EnrollStatusChange.enroll:
|
||||
award_enrollment_badge(user)
|
||||
@@ -3,24 +3,8 @@ Database models for the badges app
|
||||
"""
|
||||
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from jsonfield import JSONField
|
||||
from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
|
||||
from model_utils.models import TimeStampedModel
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from lms.djangoapps.badges.utils import deserialize_count_specs
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
|
||||
def validate_badge_image(image):
|
||||
@@ -39,298 +23,3 @@ def validate_lowercase(string):
|
||||
"""
|
||||
if not string.islower():
|
||||
raise ValidationError(_("This value must be all lowercase."))
|
||||
|
||||
|
||||
class CourseBadgesDisabledError(Exception):
|
||||
"""
|
||||
Exception raised when Course Badges aren't enabled, but an attempt to fetch one is made anyway.
|
||||
"""
|
||||
|
||||
|
||||
class BadgeClass(models.Model):
|
||||
"""
|
||||
Specifies a badge class to be registered with a backend.
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
slug = models.SlugField(max_length=255, validators=[validate_lowercase])
|
||||
badgr_server_slug = models.SlugField(max_length=255, default='', blank=True)
|
||||
issuing_component = models.SlugField(max_length=50, default='', blank=True, validators=[validate_lowercase])
|
||||
display_name = models.CharField(max_length=255)
|
||||
course_id = CourseKeyField(max_length=255, blank=True, default=None)
|
||||
description = models.TextField()
|
||||
criteria = models.TextField()
|
||||
# Mode a badge was awarded for. Included for legacy/migration purposes.
|
||||
mode = models.CharField(max_length=100, default='', blank=True)
|
||||
image = models.ImageField(upload_to='badge_classes', validators=[validate_badge_image])
|
||||
|
||||
def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
|
||||
return HTML("<Badge '{slug}' for '{issuing_component}'>").format(
|
||||
slug=HTML(self.slug), issuing_component=HTML(self.issuing_component)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_badge_class(
|
||||
cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None,
|
||||
mode='', course_id=None, create=True
|
||||
):
|
||||
# TODO method should be renamed to getorcreate instead
|
||||
"""
|
||||
Looks up a badge class by its slug, issuing component, and course_id and returns it should it exist.
|
||||
If it does not exist, and create is True, creates it according to the arguments. Otherwise, returns None.
|
||||
|
||||
The expectation is that an XBlock or platform developer should not need to concern themselves with whether
|
||||
or not a badge class has already been created, but should just feed all requirements to this function
|
||||
and it will 'do the right thing'. It should be the exception, rather than the common case, that a badge class
|
||||
would need to be looked up without also being created were it missing.
|
||||
"""
|
||||
slug = slug.lower()
|
||||
issuing_component = issuing_component.lower()
|
||||
if course_id and not modulestore().get_course(course_id).issue_badges:
|
||||
raise CourseBadgesDisabledError("This course does not have badges enabled.")
|
||||
if not course_id:
|
||||
course_id = CourseKeyField.Empty
|
||||
try:
|
||||
return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id)
|
||||
except cls.DoesNotExist:
|
||||
if not create:
|
||||
return None
|
||||
badge_class = cls(
|
||||
slug=slug,
|
||||
issuing_component=issuing_component,
|
||||
display_name=display_name,
|
||||
course_id=course_id,
|
||||
mode=mode,
|
||||
description=description,
|
||||
criteria=criteria,
|
||||
)
|
||||
badge_class.image.save(image_file_handle.name, image_file_handle)
|
||||
badge_class.full_clean()
|
||||
badge_class.save()
|
||||
return badge_class
|
||||
|
||||
@lazy
|
||||
def backend(self):
|
||||
"""
|
||||
Loads the badging backend.
|
||||
"""
|
||||
module, klass = settings.BADGING_BACKEND.rsplit('.', 1)
|
||||
module = import_module(module)
|
||||
return getattr(module, klass)()
|
||||
|
||||
def get_for_user(self, user):
|
||||
"""
|
||||
Get the assertion for this badge class for this user, if it has been awarded.
|
||||
"""
|
||||
return self.badgeassertion_set.filter(user=user)
|
||||
|
||||
def award(self, user, evidence_url=None):
|
||||
"""
|
||||
Contacts the backend to have a badge assertion created for this badge class for this user.
|
||||
"""
|
||||
return self.backend.award(self, user, evidence_url=evidence_url) # lint-amnesty, pylint: disable=no-member
|
||||
|
||||
def save(self, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
|
||||
"""
|
||||
Slugs must always be lowercase.
|
||||
"""
|
||||
self.slug = self.slug and self.slug.lower()
|
||||
self.issuing_component = self.issuing_component and self.issuing_component.lower()
|
||||
super().save(**kwargs)
|
||||
|
||||
class Meta:
|
||||
app_label = "badges"
|
||||
unique_together = (('slug', 'issuing_component', 'course_id'),)
|
||||
verbose_name_plural = "Badge Classes"
|
||||
|
||||
|
||||
class BadgeAssertion(TimeStampedModel):
|
||||
"""
|
||||
Tracks badges on our side of the badge baking transaction
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
badge_class = models.ForeignKey(BadgeClass, on_delete=models.CASCADE)
|
||||
data = JSONField()
|
||||
backend = models.CharField(max_length=50)
|
||||
image_url = models.URLField()
|
||||
assertion_url = models.URLField()
|
||||
|
||||
def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
|
||||
return HTML("<{username} Badge Assertion for {slug} for {issuing_component}").format(
|
||||
username=HTML(self.user.username),
|
||||
slug=HTML(self.badge_class.slug),
|
||||
issuing_component=HTML(self.badge_class.issuing_component),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def assertions_for_user(cls, user, course_id=None):
|
||||
"""
|
||||
Get all assertions for a user, optionally constrained to a course.
|
||||
"""
|
||||
if course_id:
|
||||
return cls.objects.filter(user=user, badge_class__course_id=course_id)
|
||||
return cls.objects.filter(user=user)
|
||||
|
||||
class Meta:
|
||||
app_label = "badges"
|
||||
|
||||
|
||||
# Abstract model doesn't index this, so we have to.
|
||||
BadgeAssertion._meta.get_field('created').db_index = True
|
||||
|
||||
|
||||
class CourseCompleteImageConfiguration(models.Model):
|
||||
"""
|
||||
Contains the icon configuration for badges for a specific course mode.
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
mode = models.CharField(
|
||||
max_length=125,
|
||||
help_text=_('The course mode for this badge image. For example, "verified" or "honor".'),
|
||||
unique=True,
|
||||
)
|
||||
icon = models.ImageField(
|
||||
# Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
|
||||
help_text=_(
|
||||
"Badge images must be square PNG files. The file size should be under 250KB."
|
||||
),
|
||||
upload_to='course_complete_badges',
|
||||
validators=[validate_badge_image]
|
||||
)
|
||||
default = models.BooleanField(
|
||||
help_text=_(
|
||||
"Set this value to True if you want this image to be the default image for any course modes "
|
||||
"that do not have a specified badge image. You can have only one default image."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
|
||||
return HTML("<CourseCompleteImageConfiguration for '{mode}'{default}>").format(
|
||||
mode=HTML(self.mode),
|
||||
default=HTML(" (default)") if self.default else HTML('')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Make sure there's not more than one default.
|
||||
"""
|
||||
if self.default and CourseCompleteImageConfiguration.objects.filter(default=True).exclude(id=self.id):
|
||||
raise ValidationError(_("There can be only one default image."))
|
||||
|
||||
@classmethod
|
||||
def image_for_mode(cls, mode):
|
||||
"""
|
||||
Get the image for a particular mode.
|
||||
"""
|
||||
try:
|
||||
return cls.objects.get(mode=mode).icon
|
||||
except cls.DoesNotExist:
|
||||
# Fall back to default, if there is one.
|
||||
return cls.objects.get(default=True).icon
|
||||
|
||||
class Meta:
|
||||
app_label = "badges"
|
||||
|
||||
|
||||
class CourseEventBadgesConfiguration(ConfigurationModel):
|
||||
"""
|
||||
Determines the settings for meta course awards-- such as completing a certain
|
||||
number of courses or enrolling in a certain number of them.
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
courses_completed = models.TextField(
|
||||
blank=True, default='',
|
||||
help_text=_(
|
||||
"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a "
|
||||
"badge class you have created that has the issuing component 'openedx__course'. "
|
||||
"For example: 3,enrolled_3_courses"
|
||||
)
|
||||
)
|
||||
courses_enrolled = models.TextField(
|
||||
blank=True, default='',
|
||||
help_text=_(
|
||||
"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a "
|
||||
"badge class you have created that has the issuing component 'openedx__course'. "
|
||||
"For example: 3,enrolled_3_courses"
|
||||
)
|
||||
)
|
||||
course_groups = models.TextField(
|
||||
blank=True, default='',
|
||||
help_text=_(
|
||||
"Each line is a comma-separated list. The first item in each line is the slug of a badge class you "
|
||||
"have created that has an issuing component of 'openedx__course'. The remaining items in each line are "
|
||||
"the course keys the learner needs to complete to be awarded the badge. For example: "
|
||||
"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
|
||||
)
|
||||
)
|
||||
|
||||
def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
|
||||
return HTML("<CourseEventBadgesConfiguration ({})>").format(
|
||||
Text("Enabled") if self.enabled else Text("Disabled")
|
||||
)
|
||||
|
||||
@property
|
||||
def completed_settings(self):
|
||||
"""
|
||||
Parses the settings from the courses_completed field.
|
||||
"""
|
||||
return deserialize_count_specs(self.courses_completed)
|
||||
|
||||
@property
|
||||
def enrolled_settings(self):
|
||||
"""
|
||||
Parses the settings from the courses_completed field.
|
||||
"""
|
||||
return deserialize_count_specs(self.courses_enrolled)
|
||||
|
||||
@property
|
||||
def course_group_settings(self):
|
||||
"""
|
||||
Parses the course group settings. In example, the format is:
|
||||
|
||||
slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second
|
||||
"""
|
||||
specs = self.course_groups.strip()
|
||||
if not specs:
|
||||
return {}
|
||||
specs = [line.split(',', 1) for line in specs.splitlines()]
|
||||
return {
|
||||
slug.strip().lower(): [CourseKey.from_string(key.strip()) for key in keys.strip().split(',')]
|
||||
for slug, keys in specs
|
||||
}
|
||||
|
||||
def clean_fields(self, exclude=tuple()):
|
||||
"""
|
||||
Verify the settings are parseable.
|
||||
"""
|
||||
errors = {}
|
||||
error_message = _("Please check the syntax of your entry.")
|
||||
if 'courses_completed' not in exclude:
|
||||
try:
|
||||
self.completed_settings
|
||||
except (ValueError, InvalidKeyError):
|
||||
errors['courses_completed'] = [str(error_message)]
|
||||
if 'courses_enrolled' not in exclude:
|
||||
try:
|
||||
self.enrolled_settings
|
||||
except (ValueError, InvalidKeyError):
|
||||
errors['courses_enrolled'] = [str(error_message)]
|
||||
if 'course_groups' not in exclude:
|
||||
store = modulestore()
|
||||
try:
|
||||
for key_list in self.course_group_settings.values():
|
||||
for course_key in key_list:
|
||||
if not store.get_course(course_key):
|
||||
ValueError(f"The course {course_key} does not exist.")
|
||||
except (ValueError, InvalidKeyError):
|
||||
errors['course_groups'] = [str(error_message)]
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
class Meta:
|
||||
app_label = "badges"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
Badging service for XBlocks
|
||||
"""
|
||||
|
||||
|
||||
from lms.djangoapps.badges.models import BadgeClass
|
||||
|
||||
|
||||
class BadgingService:
|
||||
"""
|
||||
A class that provides functions for managing badges which XBlocks can use.
|
||||
|
||||
If course_enabled is True, course-level badges are permitted for this course.
|
||||
|
||||
If it is False, any badges that are awarded should be non-course specific.
|
||||
"""
|
||||
course_badges_enabled = False
|
||||
|
||||
def __init__(self, course_id=None, modulestore=None):
|
||||
"""
|
||||
Sets the 'course_badges_enabled' parameter.
|
||||
"""
|
||||
if not (course_id and modulestore):
|
||||
return
|
||||
|
||||
course = modulestore.get_course(course_id)
|
||||
if course:
|
||||
self.course_badges_enabled = course.issue_badges
|
||||
|
||||
get_badge_class = BadgeClass.get_badge_class
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
Factories for Badge tests
|
||||
"""
|
||||
|
||||
|
||||
from random import random
|
||||
|
||||
import factory
|
||||
from django.core.files.base import ContentFile
|
||||
from factory.django import ImageField
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.badges.models import ( # lint-amnesty, pylint: disable=line-too-long
|
||||
BadgeAssertion,
|
||||
BadgeClass,
|
||||
CourseCompleteImageConfiguration,
|
||||
CourseEventBadgesConfiguration
|
||||
)
|
||||
|
||||
|
||||
def generate_dummy_image(_unused):
|
||||
"""
|
||||
Used for image fields to create a sane default.
|
||||
"""
|
||||
return ContentFile(
|
||||
ImageField()._make_data( # pylint: disable=protected-access
|
||||
{'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'}
|
||||
), 'test.png'
|
||||
)
|
||||
|
||||
|
||||
class CourseCompleteImageConfigurationFactory(factory.django.DjangoModelFactory):
|
||||
"""
|
||||
Factory for BadgeImageConfigurations
|
||||
"""
|
||||
class Meta:
|
||||
model = CourseCompleteImageConfiguration
|
||||
|
||||
mode = 'honor'
|
||||
icon = factory.LazyAttribute(generate_dummy_image)
|
||||
|
||||
|
||||
class BadgeClassFactory(factory.django.DjangoModelFactory):
|
||||
"""
|
||||
Factory for BadgeClass
|
||||
"""
|
||||
class Meta:
|
||||
model = BadgeClass
|
||||
|
||||
slug = 'test_slug'
|
||||
badgr_server_slug = 'test_badgr_server_slug'
|
||||
issuing_component = 'test_component'
|
||||
display_name = 'Test Badge'
|
||||
description = "Yay! It's a test badge."
|
||||
criteria = 'https://example.com/syllabus'
|
||||
mode = 'honor'
|
||||
image = factory.LazyAttribute(generate_dummy_image)
|
||||
|
||||
|
||||
class RandomBadgeClassFactory(BadgeClassFactory):
|
||||
"""
|
||||
Same as BadgeClassFactory, but randomize the slug.
|
||||
"""
|
||||
slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()).replace('.', '_'))
|
||||
|
||||
|
||||
class BadgeAssertionFactory(factory.django.DjangoModelFactory):
|
||||
"""
|
||||
Factory for BadgeAssertions
|
||||
"""
|
||||
class Meta:
|
||||
model = BadgeAssertion
|
||||
|
||||
user = factory.SubFactory(UserFactory)
|
||||
badge_class = factory.SubFactory(RandomBadgeClassFactory)
|
||||
data = {}
|
||||
assertion_url = 'http://example.com/example.json'
|
||||
image_url = 'http://example.com/image.png'
|
||||
|
||||
|
||||
class CourseEventBadgesConfigurationFactory(factory.django.DjangoModelFactory):
|
||||
"""
|
||||
Factory for CourseEventsBadgesConfiguration
|
||||
"""
|
||||
class Meta:
|
||||
model = CourseEventBadgesConfiguration
|
||||
|
||||
enabled = True
|
||||
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
Tests for the Badges app models.
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.images import ImageFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from path import Path
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.badges.models import (
|
||||
BadgeAssertion,
|
||||
BadgeClass,
|
||||
CourseBadgesDisabledError,
|
||||
CourseCompleteImageConfiguration,
|
||||
validate_badge_image
|
||||
)
|
||||
from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory
|
||||
from lms.djangoapps.certificates.tests.test_models import TEST_DATA_ROOT, TEST_DATA_DIR
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_image(name):
|
||||
"""
|
||||
Get one of the test images from the test data directory.
|
||||
"""
|
||||
with open(f'{TEST_DATA_DIR}/badges/{name}.png', mode='rb') as fimage:
|
||||
yield ImageFile(fimage)
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=TEST_DATA_ROOT)
|
||||
class BadgeImageConfigurationTest(TestCase):
|
||||
"""
|
||||
Test the validation features of BadgeImageConfiguration.
|
||||
"""
|
||||
|
||||
def tearDown(self): # lint-amnesty, pylint: disable=super-method-not-called
|
||||
tmp_path = Path(TEST_DATA_ROOT / 'course_complete_badges')
|
||||
Path.rmtree_p(tmp_path)
|
||||
|
||||
def test_no_double_default(self):
|
||||
"""
|
||||
Verify that creating two configurations as default is not permitted.
|
||||
"""
|
||||
with get_image('good') as image_handle:
|
||||
CourseCompleteImageConfiguration(mode='test', icon=ImageFile(image_handle), default=True).save()
|
||||
with get_image('good') as image_handle:
|
||||
pytest.raises(ValidationError, CourseCompleteImageConfiguration(mode='test2', icon=ImageFile(image_handle),
|
||||
default=True).full_clean)
|
||||
|
||||
def test_runs_validator(self):
|
||||
"""
|
||||
Verify that the image validator is triggered when cleaning the model.
|
||||
"""
|
||||
with get_image('unbalanced') as image_handle:
|
||||
pytest.raises(
|
||||
ValidationError,
|
||||
CourseCompleteImageConfiguration(mode='test2', icon=ImageFile(image_handle)).full_clean
|
||||
)
|
||||
|
||||
|
||||
class DummyBackend:
|
||||
"""
|
||||
Dummy badge backend, used for testing.
|
||||
"""
|
||||
award = Mock()
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=TEST_DATA_ROOT)
|
||||
class BadgeClassTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Test BadgeClass functionality
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.addCleanup(self.cleanup_uploads)
|
||||
|
||||
def cleanup_uploads(self):
|
||||
"""
|
||||
Remove all files uploaded as badges.
|
||||
"""
|
||||
upload_to = BadgeClass._meta.get_field('image').upload_to
|
||||
if default_storage.exists(upload_to):
|
||||
(_, files) = default_storage.listdir(upload_to)
|
||||
for uploaded_file in files:
|
||||
default_storage.delete(upload_to + '/' + uploaded_file)
|
||||
|
||||
# Need full path to make sure class names line up.
|
||||
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.tests.test_models.DummyBackend')
|
||||
def test_backend(self):
|
||||
"""
|
||||
Verify the BadgeClass fetches the backend properly.
|
||||
"""
|
||||
assert isinstance(BadgeClass().backend, DummyBackend)
|
||||
|
||||
def test_get_badge_class_preexisting(self):
|
||||
"""
|
||||
Verify fetching a badge first grabs existing badges.
|
||||
"""
|
||||
premade_badge_class = BadgeClassFactory.create()
|
||||
# Ignore additional parameters. This class already exists.
|
||||
with get_image('good') as image_handle:
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug='test_slug', issuing_component='test_component', description='Attempted override',
|
||||
criteria='test', display_name='Testola', image_file_handle=image_handle
|
||||
)
|
||||
# These defaults are set on the factory.
|
||||
assert badge_class.criteria == 'https://example.com/syllabus'
|
||||
assert badge_class.display_name == 'Test Badge'
|
||||
assert badge_class.description == "Yay! It's a test badge."
|
||||
# File name won't always be the same.
|
||||
assert badge_class.image.path == premade_badge_class.image.path
|
||||
|
||||
def test_unique_for_course(self):
|
||||
"""
|
||||
Verify that the course_id is used in fetching existing badges or creating new ones.
|
||||
"""
|
||||
course_key = CourseFactory.create().location.course_key
|
||||
premade_badge_class = BadgeClassFactory.create(course_id=course_key)
|
||||
with get_image('good') as image_handle:
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug='test_slug', issuing_component='test_component', description='Attempted override',
|
||||
criteria='test', display_name='Testola', image_file_handle=image_handle
|
||||
)
|
||||
with get_image('good') as image_handle:
|
||||
course_badge_class = BadgeClass.get_badge_class(
|
||||
slug='test_slug', issuing_component='test_component', description='Attempted override',
|
||||
criteria='test', display_name='Testola', image_file_handle=image_handle,
|
||||
course_id=course_key,
|
||||
)
|
||||
assert badge_class.id != course_badge_class.id
|
||||
assert course_badge_class.id == premade_badge_class.id
|
||||
|
||||
def test_get_badge_class_course_disabled(self):
|
||||
"""
|
||||
Verify attempting to fetch a badge class for a course which does not issue badges raises an
|
||||
exception.
|
||||
"""
|
||||
course_key = CourseFactory.create(metadata={'issue_badges': False}).location.course_key
|
||||
with pytest.raises(CourseBadgesDisabledError):
|
||||
with get_image('good') as image_handle:
|
||||
BadgeClass.get_badge_class(
|
||||
slug='test_slug', issuing_component='test_component', description='Attempted override',
|
||||
criteria='test', display_name='Testola', image_file_handle=image_handle,
|
||||
course_id=course_key,
|
||||
)
|
||||
|
||||
def test_get_badge_class_create(self):
|
||||
"""
|
||||
Verify fetching a badge creates it if it doesn't yet exist.
|
||||
"""
|
||||
with get_image('good') as image_handle:
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug='new_slug', issuing_component='new_component', description='This is a test',
|
||||
criteria='https://example.com/test_criteria', display_name='Super Badge',
|
||||
image_file_handle=image_handle
|
||||
)
|
||||
# This should have been saved before being passed back.
|
||||
assert badge_class.id
|
||||
assert badge_class.slug == 'new_slug'
|
||||
assert badge_class.issuing_component == 'new_component'
|
||||
assert badge_class.description == 'This is a test'
|
||||
assert badge_class.criteria == 'https://example.com/test_criteria'
|
||||
assert badge_class.display_name == 'Super Badge'
|
||||
assert 'good' in badge_class.image.name.rsplit('/', 1)[(- 1)]
|
||||
|
||||
def test_get_badge_class_nocreate(self):
|
||||
"""
|
||||
Test returns None if the badge class does not exist.
|
||||
"""
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug='new_slug', issuing_component='new_component', create=False
|
||||
)
|
||||
assert badge_class is None
|
||||
# Run this twice to verify there wasn't a background creation of the badge.
|
||||
badge_class = BadgeClass.get_badge_class(
|
||||
slug='new_slug', issuing_component='new_component', description=None,
|
||||
criteria=None, display_name=None,
|
||||
image_file_handle=None, create=False
|
||||
)
|
||||
assert badge_class is None
|
||||
|
||||
def test_get_badge_class_image_validate(self):
|
||||
"""
|
||||
Verify handing a broken image to get_badge_class raises a validation error upon creation.
|
||||
"""
|
||||
# TODO Test should be updated, this doc doesn't makes sense, the object eventually gets created
|
||||
with get_image('unbalanced') as image_handle:
|
||||
self.assertRaises(
|
||||
ValidationError,
|
||||
BadgeClass.get_badge_class,
|
||||
slug='new_slug', issuing_component='new_component', description='This is a test',
|
||||
criteria='https://example.com/test_criteria', display_name='Super Badge',
|
||||
image_file_handle=image_handle
|
||||
)
|
||||
|
||||
def test_get_badge_class_data_validate(self):
|
||||
"""
|
||||
Verify handing incomplete data for required fields when making a badge class raises an Integrity error.
|
||||
"""
|
||||
with pytest.raises(IntegrityError), self.allow_transaction_exception():
|
||||
with get_image('good') as image_handle:
|
||||
BadgeClass.get_badge_class(
|
||||
slug='new_slug', issuing_component='new_component', image_file_handle=image_handle
|
||||
)
|
||||
|
||||
def test_get_for_user(self):
|
||||
"""
|
||||
Make sure we can get an assertion for a user if there is one.
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
badge_class = BadgeClassFactory.create()
|
||||
assert not badge_class.get_for_user(user)
|
||||
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=user)
|
||||
assert list(badge_class.get_for_user(user)) == [assertion]
|
||||
|
||||
@override_settings(
|
||||
BADGING_BACKEND='lms.djangoapps.badges.backends.badgr.BadgrBackend',
|
||||
BADGR_USERNAME='example@example.com',
|
||||
BADGR_PASSWORD='password',
|
||||
BADGR_TOKENS_CACHE_KEY='badgr-test-cache-key')
|
||||
@patch('lms.djangoapps.badges.backends.badgr.BadgrBackend.award')
|
||||
def test_award(self, mock_award):
|
||||
"""
|
||||
Verify that the award command calls the award function on the backend with the right parameters.
|
||||
"""
|
||||
user = UserFactory.create()
|
||||
badge_class = BadgeClassFactory.create()
|
||||
badge_class.award(user, evidence_url='http://example.com/evidence')
|
||||
assert mock_award.called
|
||||
mock_award.assert_called_with(badge_class, user, evidence_url='http://example.com/evidence')
|
||||
|
||||
def test_runs_validator(self):
|
||||
"""
|
||||
Verify that the image validator is triggered when cleaning the model.
|
||||
"""
|
||||
with get_image('unbalanced') as image_handle:
|
||||
pytest.raises(
|
||||
ValidationError,
|
||||
BadgeClass(
|
||||
slug='test', issuing_component='test2', criteria='test3',
|
||||
description='test4', image=ImageFile(image_handle)).full_clean
|
||||
)
|
||||
|
||||
|
||||
class BadgeAssertionTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the BadgeAssertion model
|
||||
"""
|
||||
def test_assertions_for_user(self):
|
||||
"""
|
||||
Verify that grabbing all assertions for a user behaves as expected.
|
||||
|
||||
This function uses object IDs because for some reason Jenkins trips up
|
||||
on its assertCountEqual check here despite the items being equal.
|
||||
"""
|
||||
user = UserFactory()
|
||||
assertions = [BadgeAssertionFactory.create(user=user).id for _i in range(3)]
|
||||
course = CourseFactory.create()
|
||||
course_key = course.location.course_key
|
||||
course_badges = [RandomBadgeClassFactory(course_id=course_key) for _i in range(3)]
|
||||
course_assertions = [
|
||||
BadgeAssertionFactory.create(user=user, badge_class=badge_class).id for badge_class in course_badges
|
||||
]
|
||||
assertions.extend(course_assertions)
|
||||
assertions.sort()
|
||||
assertions_for_user = [badge.id for badge in BadgeAssertion.assertions_for_user(user)]
|
||||
assertions_for_user.sort()
|
||||
assert assertions_for_user == assertions
|
||||
course_scoped_assertions = [
|
||||
badge.id for badge in BadgeAssertion.assertions_for_user(user, course_id=course_key)
|
||||
]
|
||||
course_scoped_assertions.sort()
|
||||
assert course_scoped_assertions == course_assertions
|
||||
|
||||
|
||||
class ValidBadgeImageTest(TestCase):
|
||||
"""
|
||||
Tests the badge image field validator.
|
||||
"""
|
||||
def test_good_image(self):
|
||||
"""
|
||||
Verify that saving a valid badge image is no problem.
|
||||
"""
|
||||
with get_image('good') as image_handle:
|
||||
validate_badge_image(ImageFile(image_handle))
|
||||
|
||||
def test_unbalanced_image(self):
|
||||
"""
|
||||
Verify that setting an image with an uneven width and height raises an error.
|
||||
"""
|
||||
with get_image('unbalanced') as image_handle:
|
||||
self.assertRaises(ValidationError, validate_badge_image, ImageFile(image_handle))
|
||||
|
||||
def test_large_image(self):
|
||||
"""
|
||||
Verify that setting an image that is too big raises an error.
|
||||
"""
|
||||
with get_image('large') as image_handle:
|
||||
self.assertRaises(ValidationError, validate_badge_image, ImageFile(image_handle))
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Utility functions used by the badging app.
|
||||
"""
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def site_prefix():
|
||||
"""
|
||||
Get the prefix for the site URL-- protocol and server name.
|
||||
"""
|
||||
scheme = "https" if settings.HTTPS == "on" else "http"
|
||||
return f'{scheme}://{settings.SITE_NAME}'
|
||||
|
||||
|
||||
def requires_badges_enabled(function):
|
||||
"""
|
||||
Decorator that bails a function out early if badges aren't enabled.
|
||||
"""
|
||||
def wrapped(*args, **kwargs):
|
||||
"""
|
||||
Wrapped function which bails out early if bagdes aren't enabled.
|
||||
"""
|
||||
if not badges_enabled():
|
||||
return
|
||||
return function(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def badges_enabled():
|
||||
"""
|
||||
returns a boolean indicating whether or not openbadges are enabled.
|
||||
"""
|
||||
return settings.FEATURES.get('ENABLE_OPENBADGES', False)
|
||||
|
||||
|
||||
def deserialize_count_specs(text):
|
||||
"""
|
||||
Takes a string in the format of:
|
||||
int,course_key
|
||||
int,course_key
|
||||
|
||||
And returns a dictionary with the keys as the numbers and the values as the course keys.
|
||||
"""
|
||||
specs = text.splitlines()
|
||||
specs = [line.split(',') for line in specs if line.strip()]
|
||||
return {int(num): slug.strip().lower() for num, slug in specs}
|
||||
@@ -26,8 +26,6 @@ from simple_history.models import HistoricalRecords
|
||||
from common.djangoapps.student import models_api as student_api
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
|
||||
from lms.djangoapps.badges.events.course_complete import course_badge_check
|
||||
from lms.djangoapps.badges.events.course_meta import completion_check, course_group_check
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.instructor_task.models import InstructorTask
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED
|
||||
@@ -1245,31 +1243,6 @@ class CertificateTemplateAsset(TimeStampedModel):
|
||||
app_label = "certificates"
|
||||
|
||||
|
||||
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
|
||||
# pylint: disable=unused-argument
|
||||
def create_course_badge(sender, user, course_key, status, **kwargs):
|
||||
"""
|
||||
Standard signal hook to create course badges when a certificate has been generated.
|
||||
"""
|
||||
course_badge_check(user, course_key)
|
||||
|
||||
|
||||
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
|
||||
def create_completion_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Standard signal hook to create 'x courses completed' badges when a certificate has been generated.
|
||||
"""
|
||||
completion_check(user)
|
||||
|
||||
|
||||
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
|
||||
def create_course_group_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Standard signal hook to create badges when a user has completed a prespecified set of courses.
|
||||
"""
|
||||
course_group_check(user, course_key)
|
||||
|
||||
|
||||
class CertificateGenerationCommandConfiguration(ConfigurationModel):
|
||||
"""
|
||||
Manages configuration for a run of the cert_generation management command.
|
||||
|
||||
@@ -20,12 +20,6 @@ from common.djangoapps.student.roles import CourseStaffRole
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from common.djangoapps.track.tests import EventTrackingTestCase
|
||||
from common.djangoapps.util.date_utils import strftime_localized
|
||||
from lms.djangoapps.badges.events.course_complete import get_completion_badge
|
||||
from lms.djangoapps.badges.tests.factories import (
|
||||
BadgeAssertionFactory,
|
||||
BadgeClassFactory,
|
||||
CourseCompleteImageConfigurationFactory
|
||||
)
|
||||
from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION
|
||||
from lms.djangoapps.certificates.models import (
|
||||
CertificateGenerationCourseSetting,
|
||||
@@ -58,8 +52,6 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p
|
||||
|
||||
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
|
||||
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
|
||||
FEATURES_WITH_BADGES_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy()
|
||||
FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
|
||||
|
||||
FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy()
|
||||
FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False
|
||||
@@ -118,7 +110,6 @@ class CommonCertificatesTestCase(ModuleStoreTestCase):
|
||||
)
|
||||
CertificateHtmlViewConfigurationFactory.create()
|
||||
LinkedInAddToProfileConfigurationFactory.create()
|
||||
CourseCompleteImageConfigurationFactory.create()
|
||||
|
||||
def _add_course_certificates(self, count=1, signatory_count=0, is_active=True):
|
||||
"""
|
||||
@@ -533,32 +524,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase)
|
||||
self.assertContains(response, f'<title>test_organization {self.course.number} Certificate |')
|
||||
self.assertContains(response, 'logo_test1.png')
|
||||
|
||||
@ddt.data(True, False)
|
||||
@patch('lms.djangoapps.certificates.views.webview.get_completion_badge')
|
||||
def test_fetch_badge_info(self, issue_badges, mock_get_completion_badge):
|
||||
"""
|
||||
Test: Fetch badge class info if badges are enabled.
|
||||
"""
|
||||
if issue_badges:
|
||||
features = FEATURES_WITH_BADGES_ENABLED
|
||||
else:
|
||||
features = FEATURES_WITH_CERTS_ENABLED
|
||||
with override_settings(FEATURES=features):
|
||||
badge_class = BadgeClassFactory(course_id=self.course_id, mode=self.cert.mode)
|
||||
mock_get_completion_badge.return_value = badge_class
|
||||
|
||||
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
|
||||
test_url = get_certificate_url(user_id=self.user.id, course_id=self.cert.course_id,
|
||||
uuid=self.cert.verify_uuid)
|
||||
response = self.client.get(test_url)
|
||||
assert response.status_code == 200
|
||||
|
||||
if issue_badges:
|
||||
mock_get_completion_badge.assert_called()
|
||||
else:
|
||||
mock_get_completion_badge.assert_not_called()
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
|
||||
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
|
||||
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
|
||||
"CERTIFICATE_TWITTER": True,
|
||||
"CERTIFICATE_FACEBOOK": True,
|
||||
@@ -589,10 +555,6 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase)
|
||||
test_org = organizations_api.add_organization(organization_data=test_organization_data)
|
||||
organizations_api.add_organization_course(organization_data=test_org, course_key=str(self.course.id))
|
||||
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
|
||||
badge_class = get_completion_badge(course_id=self.course_id, user=self.user)
|
||||
BadgeAssertionFactory.create(
|
||||
user=self.user, badge_class=badge_class,
|
||||
)
|
||||
self.course.cert_html_view_overrides = {
|
||||
"logo_src": "/static/certificates/images/course_override_logo.png"
|
||||
}
|
||||
@@ -629,8 +591,6 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase)
|
||||
partner_long_name=long_org_name,
|
||||
),
|
||||
)
|
||||
# Test item from badge info
|
||||
self.assertContains(response, "Add to Mozilla Backpack")
|
||||
# Test item from site configuration
|
||||
self.assertContains(response, "https://www.test-site.org/about-us")
|
||||
# Test course overrides
|
||||
@@ -1784,53 +1744,3 @@ class CertificateEventTests(CommonCertificatesTestCase, EventTrackingTestCase):
|
||||
},
|
||||
actual_event['data']
|
||||
)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
|
||||
def test_evidence_event_sent(self):
|
||||
self._add_course_certificates(count=1, signatory_count=2)
|
||||
|
||||
cert_url = get_certificate_url(
|
||||
user_id=self.user.id,
|
||||
course_id=self.course_id,
|
||||
uuid=self.cert.verify_uuid
|
||||
)
|
||||
test_url = f'{cert_url}?evidence_visit=1'
|
||||
self.recreate_tracker()
|
||||
badge_class = get_completion_badge(self.course_id, self.user)
|
||||
assertion = BadgeAssertionFactory.create(
|
||||
user=self.user, badge_class=badge_class,
|
||||
backend='DummyBackend',
|
||||
image_url='https://www.example.com/image.png',
|
||||
assertion_url='https://www.example.com/assertion.json',
|
||||
data={
|
||||
'issuer': 'https://www.example.com/issuer.json',
|
||||
}
|
||||
)
|
||||
response = self.client.get(test_url)
|
||||
|
||||
# There are two events being emitted in this flow.
|
||||
# One for page hit (due to the tracker in the middleware) and
|
||||
# one due to the certificate being visited.
|
||||
# We are interested in the second one.
|
||||
actual_event = self.get_event(1)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert_event_matches(
|
||||
{
|
||||
'name': 'edx.badge.assertion.evidence_visited',
|
||||
'data': {
|
||||
'course_id': 'course-v1:testorg+run1+refundable_course',
|
||||
'assertion_id': assertion.id,
|
||||
'badge_generator': 'DummyBackend',
|
||||
'badge_name': 'refundable course',
|
||||
'issuing_component': '',
|
||||
'badge_slug': 'course-v1testorgrun1refundable_course_honor_927f3ad',
|
||||
'assertion_json_url': 'https://www.example.com/assertion.json',
|
||||
'assertion_image_url': 'https://www.example.com/image.png',
|
||||
'user_id': self.user.id,
|
||||
'issuer': 'https://www.example.com/issuer.json',
|
||||
'enrollment_mode': 'honor',
|
||||
},
|
||||
},
|
||||
actual_event
|
||||
)
|
||||
|
||||
@@ -12,9 +12,8 @@ from milestones.tests.utils import MilestonesTestCaseMixin
|
||||
from pytz import UTC
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.util.milestones_helpers import milestones_achieved_by_user, set_prerequisite_courses
|
||||
from lms.djangoapps.badges.tests.factories import CourseCompleteImageConfigurationFactory
|
||||
from lms.djangoapps.certificates.api import certificate_info_for_user, certificate_status_for_student
|
||||
from lms.djangoapps.certificates.models import (
|
||||
CertificateStatuses,
|
||||
@@ -204,20 +203,3 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
|
||||
completed_milestones = milestones_achieved_by_user(student, str(pre_requisite_course.id))
|
||||
assert len(completed_milestones) == 1
|
||||
assert completed_milestones[0]['namespace'] == str(pre_requisite_course.id)
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@patch('lms.djangoapps.badges.backends.badgr.BadgrBackend', spec=True)
|
||||
def test_badge_callback(self, handler):
|
||||
student = UserFactory()
|
||||
course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True)
|
||||
CourseCompleteImageConfigurationFactory()
|
||||
CourseEnrollmentFactory(user=student, course_id=course.location.course_key, mode='honor')
|
||||
cert = GeneratedCertificateFactory.create(
|
||||
user=student,
|
||||
course_id=course.id,
|
||||
status=CertificateStatuses.generating,
|
||||
mode='verified'
|
||||
)
|
||||
cert.status = CertificateStatuses.downloadable
|
||||
cert.save()
|
||||
assert handler.return_value.award.called
|
||||
|
||||
@@ -15,7 +15,6 @@ from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.template import RequestContext
|
||||
from django.utils import translation
|
||||
from django.utils.encoding import smart_str
|
||||
from eventtracking import tracker
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx_filters.learning.filters import CertificateRenderStarted
|
||||
@@ -27,8 +26,6 @@ from common.djangoapps.edxmako.template import Template
|
||||
from common.djangoapps.student.models import LinkedInAddToProfileConfiguration
|
||||
from common.djangoapps.util.date_utils import strftime_localized
|
||||
from common.djangoapps.util.views import handle_500
|
||||
from lms.djangoapps.badges.events.course_complete import get_completion_badge
|
||||
from lms.djangoapps.badges.utils import badges_enabled
|
||||
from lms.djangoapps.certificates.api import (
|
||||
certificates_viewable_for_course,
|
||||
display_date_for_certificate,
|
||||
@@ -396,42 +393,6 @@ def _track_certificate_events(request, course, user, user_certificate):
|
||||
"""
|
||||
Tracks web certificate view related events.
|
||||
"""
|
||||
# Badge Request Event Tracking Logic
|
||||
course_key = course.location.course_key
|
||||
|
||||
if 'evidence_visit' in request.GET:
|
||||
badge_class = get_completion_badge(course_key, user)
|
||||
if not badge_class:
|
||||
log.warning('Visit to evidence URL for badge, but badges not configured for course "%s"', course_key)
|
||||
badges = []
|
||||
else:
|
||||
badges = badge_class.get_for_user(user)
|
||||
if badges:
|
||||
# There should only ever be one of these.
|
||||
badge = badges[0]
|
||||
tracker.emit(
|
||||
'edx.badge.assertion.evidence_visited',
|
||||
{
|
||||
'badge_name': badge.badge_class.display_name,
|
||||
'badge_slug': badge.badge_class.slug,
|
||||
'badge_generator': badge.backend,
|
||||
'issuing_component': badge.badge_class.issuing_component,
|
||||
'user_id': user.id,
|
||||
'course_id': str(course_key),
|
||||
'enrollment_mode': badge.badge_class.mode,
|
||||
'assertion_id': badge.id,
|
||||
'assertion_image_url': badge.image_url,
|
||||
'assertion_json_url': badge.assertion_url,
|
||||
'issuer': badge.data.get('issuer'),
|
||||
}
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"Could not find badge for %s on course %s.",
|
||||
user.id,
|
||||
course_key,
|
||||
)
|
||||
|
||||
# track certificate evidence_visited event for analytics when certificate_user and accessing_user are different
|
||||
if request.user and request.user.id != user.id:
|
||||
emit_certificate_event('evidence_visited', user, str(course.id), event_data={
|
||||
@@ -441,18 +402,6 @@ def _track_certificate_events(request, course, user, user_certificate):
|
||||
})
|
||||
|
||||
|
||||
def _update_badge_context(context, course, user):
|
||||
"""
|
||||
Updates context with badge info.
|
||||
"""
|
||||
badge = None
|
||||
if badges_enabled() and course.issue_badges:
|
||||
badges = get_completion_badge(course.location.course_key, user).get_for_user(user)
|
||||
if badges:
|
||||
badge = badges[0]
|
||||
context['badge'] = badge
|
||||
|
||||
|
||||
def _update_organization_context(context, course):
|
||||
"""
|
||||
Updates context with organization related info.
|
||||
@@ -630,9 +579,6 @@ def render_html_view(request, course_id, certificate=None): # pylint: disable=t
|
||||
# Append/Override the existing view context values with certificate specific values
|
||||
_update_certificate_context(context, course, course_overview, user_certificate, platform_name)
|
||||
|
||||
# Append badge info
|
||||
_update_badge_context(context, course, user)
|
||||
|
||||
# Add certificate header/footer data to current context
|
||||
context.update(get_certificate_header_context(is_secure=request.is_secure()))
|
||||
context.update(get_certificate_footer_context())
|
||||
|
||||
@@ -40,8 +40,6 @@ from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
|
||||
from xblock.reference.plugins import FSService
|
||||
from xblock.runtime import KvsFieldData
|
||||
|
||||
from lms.djangoapps.badges.service import BadgingService
|
||||
from lms.djangoapps.badges.utils import badges_enabled
|
||||
from lms.djangoapps.teams.services import TeamsService
|
||||
from openedx.core.lib.xblock_services.call_to_action import CallToActionService
|
||||
from xmodule.contentstore.django import contentstore
|
||||
@@ -630,7 +628,6 @@ def prepare_runtime_for_user(
|
||||
'partitions': PartitionService(course_id=course_id, cache=DEFAULT_REQUEST_CACHE.data),
|
||||
'settings': SettingsService(),
|
||||
'user_tags': UserTagsService(user=user, course_id=course_id),
|
||||
'badging': BadgingService(course_id=course_id, modulestore=store) if badges_enabled() else None,
|
||||
'teams': TeamsService(),
|
||||
'teams_configuration': TeamsConfigurationService(),
|
||||
'call_to_action': CallToActionService(),
|
||||
|
||||
@@ -75,8 +75,6 @@ from common.djangoapps.xblock_django.constants import (
|
||||
ATTR_KEY_USER_IS_STAFF,
|
||||
ATTR_KEY_USER_ROLE,
|
||||
)
|
||||
from lms.djangoapps.badges.tests.factories import BadgeClassFactory
|
||||
from lms.djangoapps.badges.tests.test_models import get_image
|
||||
from lms.djangoapps.courseware import block_render as render
|
||||
from lms.djangoapps.courseware.access_response import AccessResponse
|
||||
from lms.djangoapps.courseware.courses import get_course_info_section, get_course_with_access
|
||||
@@ -2377,46 +2375,6 @@ class LMSXBlockServiceBindingTest(LMSXBlockServiceMixin):
|
||||
self.block.runtime.service(self.block, 'user_tags').get_tag('fake_scope', key)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestBadgingService(LMSXBlockServiceMixin):
|
||||
"""Test the badging service interface"""
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
def test_service_rendered(self):
|
||||
self._prepare_runtime()
|
||||
assert self.block.runtime.service(self.block, 'badging')
|
||||
|
||||
def test_no_service_rendered(self):
|
||||
with pytest.raises(NoSuchServiceError):
|
||||
self.block.runtime.service(self.block, 'badging')
|
||||
|
||||
@ddt.data(True, False)
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
def test_course_badges_toggle(self, toggle):
|
||||
self.course = CourseFactory.create(metadata={'issue_badges': toggle})
|
||||
self._prepare_runtime()
|
||||
assert self.block.runtime.service(self.block, 'badging').course_badges_enabled is toggle
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
def test_get_badge_class(self):
|
||||
self._prepare_runtime()
|
||||
badge_service = self.block.runtime.service(self.block, 'badging')
|
||||
premade_badge_class = BadgeClassFactory.create()
|
||||
# Ignore additional parameters. This class already exists.
|
||||
# We should get back the first class we created, rather than a new one.
|
||||
with get_image('good') as image_handle:
|
||||
badge_class = badge_service.get_badge_class(
|
||||
slug='test_slug', issuing_component='test_component', description='Attempted override',
|
||||
criteria='test', display_name='Testola', image_file_handle=image_handle
|
||||
)
|
||||
# These defaults are set on the factory.
|
||||
assert badge_class.criteria == 'https://example.com/syllabus'
|
||||
assert badge_class.display_name == 'Test Badge'
|
||||
assert badge_class.description == "Yay! It's a test badge."
|
||||
# File name won't always be the same.
|
||||
assert badge_class.image.path == premade_badge_class.image.path
|
||||
|
||||
|
||||
class TestI18nService(LMSXBlockServiceMixin):
|
||||
""" Test XBlockI18nService """
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ AWS_ACCESS_KEY_ID: ''
|
||||
AWS_SECRET_ACCESS_KEY: ''
|
||||
BUGS_EMAIL: bugs@example.com
|
||||
BULK_EMAIL_DEFAULT_FROM_EMAIL: no-reply@example.com
|
||||
BADGING_BACKEND: 'lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend'
|
||||
BLOCK_STRUCTURES_SETTINGS:
|
||||
# We have CELERY_ALWAYS_EAGER set to True, so there's no asynchronous
|
||||
# code running and the celery routing is unimportant.
|
||||
@@ -157,8 +156,6 @@ FEATURES:
|
||||
ENABLE_DASHBOARD_SEARCH: True
|
||||
# discussion home panel, which includes a subscription on/off setting for discussion digest emails.
|
||||
ENABLE_DISCUSSION_HOME_PANEL: True
|
||||
# Enable support for OpenBadges accomplishments
|
||||
ENABLE_OPENBADGES: True
|
||||
ENABLE_LTI_PROVIDER: True
|
||||
# Enable milestones app
|
||||
MILESTONES_APP: True
|
||||
|
||||
@@ -696,19 +696,6 @@ FEATURES = {
|
||||
# .. toggle_tickets: https://github.com/openedx/edx-platform/pull/9744
|
||||
'ENABLE_SPECIAL_EXAMS': False,
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_OPENBADGES']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Enables support for the creation of OpenBadges as a method of awarding credentials.
|
||||
# .. toggle_warning: The following settings (all of which are in the same file) should be set or reviewed prior to
|
||||
# enabling this setting: BADGING_BACKEND, BADGR_API_TOKEN, BADGR_BASE_URL, BADGR_ISSUER_SLUG, BADGR_TIMEOUT.
|
||||
# Full guide for setting up OpenBadges available here:
|
||||
# https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_badging.html pylint: disable=line-too-long,useless-suppression
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2015-04-30
|
||||
# .. toggle_tickets: https://openedx.atlassian.net/browse/SOL-1325
|
||||
'ENABLE_OPENBADGES': False,
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_LTI_PROVIDER']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
@@ -3666,68 +3653,6 @@ REGISTRATION_EMAIL_PATTERNS_ALLOWED = None
|
||||
CERT_NAME_SHORT = "Certificate"
|
||||
CERT_NAME_LONG = "Certificate of Achievement"
|
||||
|
||||
#################### OpenBadges Settings #######################
|
||||
|
||||
# .. setting_name: BADGING_BACKEND
|
||||
# .. setting_default: 'lms.djangoapps.badges.backends.badgr.BadgrBackend'
|
||||
# .. setting_description: The backend service class (or callable) for creating OpenBadges. It must implement
|
||||
# the interface provided by lms.djangoapps.badges.backends.base.BadgeBackend
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGING_BACKEND = 'lms.djangoapps.badges.backends.badgr.BadgrBackend'
|
||||
|
||||
# .. setting_name: BADGR_BASE_URL
|
||||
# .. setting_default: 'http://localhost:8005'
|
||||
# .. setting_description: The base URL for the Badgr server.
|
||||
# .. setting_warning: DO NOT include a trailing slash. Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_BASE_URL = "http://localhost:8005"
|
||||
|
||||
# .. setting_name: BADGR_ISSUER_SLUG
|
||||
# .. setting_default: 'example-issuer'
|
||||
# .. setting_description: A string that is the slug for the Badgr issuer. The slug can be obtained from the URL of
|
||||
# the Badgr Server page that displays the issuer. For example, in the URL
|
||||
# http://exampleserver.com/issuer/test-issuer, the issuer slug is "test-issuer".
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_ISSUER_SLUG = "example-issuer"
|
||||
|
||||
# .. setting_name: BADGR_USERNAME
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The username for Badgr. You should set up an issuer application with Badgr
|
||||
# (https://badgr.org/app-developers/). The username and password will then be used to create or renew
|
||||
# OAuth2 tokens.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_USERNAME = None
|
||||
|
||||
# .. setting_name: BADGR_PASSWORD
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The password for Badgr. You should set up an issuer application with Badgr
|
||||
# (https://badgr.org/app-developers/). The username and password will then be used to create or renew
|
||||
# OAuth2 tokens.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_PASSWORD = None
|
||||
|
||||
# .. setting_name: BADGR_TOKENS_CACHE_KEY
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The cache key for Badgr API tokens. Once created, the tokens will be stored in cache.
|
||||
# Define the key here for setting and retrieveing the tokens.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_TOKENS_CACHE_KEY = None
|
||||
|
||||
# .. setting_name: BADGR_TIMEOUT
|
||||
# .. setting_default: 10
|
||||
# .. setting_description: Number of seconds to wait on the badging server when contacting it before giving up.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_TIMEOUT = 10
|
||||
|
||||
# .. toggle_name: BADGR_ENABLE_NOTIFICATIONS
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Optional setting for enabling email notifications. When set to "True",
|
||||
# learners will be notified by email when they earn a badge.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2021-07-29
|
||||
# .. toggle_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_ENABLE_NOTIFICATIONS = False
|
||||
|
||||
###################### Grade Downloads ######################
|
||||
# These keys are used for all of our asynchronous downloadable files, including
|
||||
# the ones that contain information other than grades.
|
||||
@@ -4208,9 +4133,6 @@ ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"] = (
|
||||
"level_of_education",
|
||||
'social_links',
|
||||
'time_zone',
|
||||
|
||||
# Not an actual field, but used to signal whether badges should be public.
|
||||
'accomplishments_shared',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
$(function() {
|
||||
'use strict';
|
||||
|
||||
$('.action-share-mozillaopenbadges').click(function(event) {
|
||||
$('.badges-overlay').fadeIn();
|
||||
event.preventDefault();
|
||||
});
|
||||
$('.badges-modal .close').click(function() {
|
||||
$('.badges-overlay').fadeOut();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('Program Details Header View', () => {
|
||||
expect(view.$('.org-logo').attr('alt'))
|
||||
.toEqual(`${context.programData.authoring_organizations[0].name}'s logo`);
|
||||
});
|
||||
|
||||
|
||||
it('should render the subscription badge if subscription is active', () => {
|
||||
expect(view.$('.meta-info .badge').html().trim()).toEqual('Subscribed');
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ define(['underscore'], function(_) {
|
||||
|
||||
var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student';
|
||||
var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student';
|
||||
var BADGES_API_URL = '/api/badges/v1/assertions/user/student/';
|
||||
var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload';
|
||||
var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove';
|
||||
var FIND_COURSES_URL = '/courses';
|
||||
@@ -116,7 +115,6 @@ define(['underscore'], function(_) {
|
||||
social_links: [{platform: 'facebook', social_link: 'https://www.facebook.com/edX'}],
|
||||
language_proficiencies: [{code: '1'}],
|
||||
profile_image: PROFILE_IMAGE,
|
||||
accomplishments_shared: false
|
||||
};
|
||||
var DEFAULT_USER_PREFERENCES_DATA = {
|
||||
'pref-lang': '2',
|
||||
@@ -223,7 +221,6 @@ define(['underscore'], function(_) {
|
||||
return {
|
||||
USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL,
|
||||
USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL,
|
||||
BADGES_API_URL: BADGES_API_URL,
|
||||
FIND_COURSES_URL: FIND_COURSES_URL,
|
||||
IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL,
|
||||
IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL,
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
language_proficiencies: [],
|
||||
requires_parental_consent: true,
|
||||
profile_image: null,
|
||||
accomplishments_shared: false,
|
||||
default_public_account_fields: [],
|
||||
extended_profile: [],
|
||||
secondary_email: ''
|
||||
|
||||
@@ -788,13 +788,9 @@
|
||||
'js/spec/views/message_banner_spec.js',
|
||||
'js/spec/views/notification_spec.js',
|
||||
'learner_profile/js/spec/learner_profile_factory_spec.js',
|
||||
'learner_profile/js/spec/views/badge_list_container_spec.js',
|
||||
'learner_profile/js/spec/views/badge_list_view_spec.js',
|
||||
'learner_profile/js/spec/views/badge_view_spec.js',
|
||||
'learner_profile/js/spec/views/learner_profile_fields_spec.js',
|
||||
'learner_profile/js/spec/views/learner_profile_view_spec.js',
|
||||
'learner_profile/js/spec/views/section_two_tab_spec.js',
|
||||
'learner_profile/js/spec/views/share_modal_view_spec.js',
|
||||
'support/js/spec/collections/enrollment_spec.js',
|
||||
'support/js/spec/models/enrollment_spec.js',
|
||||
'support/js/spec/views/certificates_spec.js',
|
||||
|
||||
@@ -227,11 +227,6 @@ if settings.FEATURES.get('ENABLE_MOBILE_REST_API'):
|
||||
re_path(r'^api/mobile/(?P<api_version>v(2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')),
|
||||
]
|
||||
|
||||
if settings.FEATURES.get('ENABLE_OPENBADGES'):
|
||||
urlpatterns += [
|
||||
path('api/badges/v1/', include(('lms.djangoapps.badges.api.urls', 'badges'), namespace='badges_api')),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path('openassessment/fileupload/', include('openassessment.fileupload.urls')),
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ COURSE_GRADE_CHANGED = Signal()
|
||||
|
||||
# Signal that fires when a user is awarded a certificate in a course (in the certificates django app)
|
||||
# TODO: runtime coupling between apps will be reduced if this event is changed to carry a username
|
||||
# rather than a User object; however, this will require changes to the milestones and badges APIs
|
||||
# rather than a User object; however, this will require changes to the milestones
|
||||
# Same providing_args=["user", "course_key", "mode", "status"] for next 3 signals.
|
||||
COURSE_CERT_CHANGED = Signal()
|
||||
COURSE_CERT_AWARDED = Signal()
|
||||
|
||||
@@ -21,7 +21,6 @@ from common.djangoapps.student.models import (
|
||||
UserPasswordToggleHistory,
|
||||
UserProfile
|
||||
)
|
||||
from lms.djangoapps.badges.utils import badges_enabled
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.user_api import errors
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
|
||||
@@ -136,7 +135,6 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d
|
||||
except ObjectDoesNotExist:
|
||||
activation_key = None
|
||||
|
||||
accomplishments_shared = badges_enabled()
|
||||
data = {
|
||||
"username": user.username,
|
||||
"url": self.context.get('request').build_absolute_uri(
|
||||
@@ -164,7 +162,6 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d
|
||||
"level_of_education": None,
|
||||
"mailing_address": None,
|
||||
"requires_parental_consent": None,
|
||||
"accomplishments_shared": accomplishments_shared,
|
||||
"account_privacy": self.configuration.get('default_visibility'),
|
||||
"social_links": None,
|
||||
"extended_profile_fields": None,
|
||||
|
||||
@@ -625,7 +625,6 @@ class AccountSettingsOnCreationTest(CreateAccountMixin, TestCase):
|
||||
'requires_parental_consent': True,
|
||||
'language_proficiencies': [],
|
||||
'account_privacy': PRIVATE_VISIBILITY,
|
||||
'accomplishments_shared': False,
|
||||
'extended_profile': [],
|
||||
'secondary_email': None,
|
||||
'secondary_email_enabled': None,
|
||||
|
||||
@@ -359,7 +359,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
|
||||
ENABLED_CACHES = ['default']
|
||||
TOTAL_QUERY_COUNT = 24
|
||||
FULL_RESPONSE_FIELD_COUNT = 30
|
||||
FULL_RESPONSE_FIELD_COUNT = 29
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -379,12 +379,12 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
legacy_profile.save()
|
||||
return year_of_birth
|
||||
|
||||
def _verify_full_shareable_account_response(self, response, account_privacy=None, badges_enabled=False):
|
||||
def _verify_full_shareable_account_response(self, response, account_privacy=None):
|
||||
"""
|
||||
Verify that the shareable fields from the account are returned
|
||||
"""
|
||||
data = response.data
|
||||
assert 12 == len(data)
|
||||
assert 11 == len(data)
|
||||
|
||||
# public fields (3)
|
||||
assert account_privacy == data['account_privacy']
|
||||
@@ -399,7 +399,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
assert 'm' == data['level_of_education']
|
||||
assert data['social_links'] is not None
|
||||
assert data['time_zone'] is None
|
||||
assert badges_enabled == data['accomplishments_shared']
|
||||
|
||||
def _verify_private_account_response(self, response, requires_parental_consent=False):
|
||||
"""
|
||||
@@ -436,7 +435,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
assert 'm' == data['level_of_education']
|
||||
assert data['social_links'] is not None
|
||||
assert UserPreference.get_value(self.user, 'time_zone') == data['time_zone']
|
||||
assert data['accomplishments_shared'] is not None
|
||||
assert ((self.user.first_name + ' ') + self.user.last_name) == data['name']
|
||||
|
||||
# additional admin fields (13)
|
||||
@@ -669,7 +667,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
response = self.send_get(self.different_client)
|
||||
self._verify_private_account_response(response)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@ddt.data(
|
||||
("client", "user", PRIVATE_VISIBILITY),
|
||||
("different_client", "different_user", PRIVATE_VISIBILITY),
|
||||
@@ -691,7 +688,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
if preference_visibility == PRIVATE_VISIBILITY:
|
||||
self._verify_private_account_response(response)
|
||||
else:
|
||||
self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY, badges_enabled=True)
|
||||
self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY)
|
||||
|
||||
client = self.login_client(api_client, requesting_username)
|
||||
|
||||
@@ -812,8 +809,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
|
||||
assert [] == data['language_proficiencies']
|
||||
assert PRIVATE_VISIBILITY == data['account_privacy']
|
||||
assert data['time_zone'] is None
|
||||
# Badges aren't on by default, so should not be present.
|
||||
assert data['accomplishments_shared'] is False
|
||||
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
verify_get_own_information(self._get_num_queries(22))
|
||||
|
||||
@@ -244,9 +244,6 @@ class AccountViewSet(ViewSet):
|
||||
If "custom", the user has selectively chosen a subset of shareable
|
||||
fields to make visible to others via the User Preferences API.
|
||||
|
||||
* accomplishments_shared: Signals whether badges are enabled on the
|
||||
platform and should be fetched.
|
||||
|
||||
* phone_number: The phone number for the user. String of numbers with
|
||||
an optional `+` sign at the start.
|
||||
|
||||
|
||||
@@ -14,14 +14,12 @@
|
||||
'js/views/fields',
|
||||
'learner_profile/js/views/learner_profile_fields',
|
||||
'learner_profile/js/views/learner_profile_view',
|
||||
'learner_profile/js/models/badges_model',
|
||||
'learner_profile/js/views/badge_list_container',
|
||||
'js/student_account/views/account_settings_fields',
|
||||
'js/views/message_banner',
|
||||
'string_utils'
|
||||
], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel,
|
||||
AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, BadgeModel,
|
||||
BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) {
|
||||
AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView,
|
||||
AccountSettingsFieldViews, MessageBannerView) {
|
||||
return function(options) {
|
||||
var $learnerProfileElement = $('.wrapper-profile');
|
||||
|
||||
@@ -55,9 +53,6 @@
|
||||
nameFieldView,
|
||||
sectionOneFieldViews,
|
||||
sectionTwoFieldViews,
|
||||
BadgeCollection,
|
||||
badgeCollection,
|
||||
badgeListContainer,
|
||||
learnerProfileView,
|
||||
getProfileVisibility,
|
||||
showLearnerProfileView;
|
||||
@@ -172,26 +167,6 @@
|
||||
})
|
||||
];
|
||||
|
||||
BadgeCollection = PagingCollection.extend({
|
||||
queryParams: {
|
||||
currentPage: 'current_page'
|
||||
}
|
||||
});
|
||||
badgeCollection = new BadgeCollection();
|
||||
badgeCollection.url = options.badges_api_url;
|
||||
|
||||
badgeListContainer = new BadgeListContainer({
|
||||
attributes: {class: 'badge-set-display'},
|
||||
collection: badgeCollection,
|
||||
find_courses_url: options.find_courses_url,
|
||||
ownProfile: options.own_profile,
|
||||
badgeMeta: {
|
||||
badges_logo: options.badges_logo,
|
||||
backpack_ui_img: options.backpack_ui_img,
|
||||
badges_icon: options.badges_icon
|
||||
}
|
||||
});
|
||||
|
||||
learnerProfileView = new LearnerProfileView({
|
||||
el: $learnerProfileElement,
|
||||
ownProfile: options.own_profile,
|
||||
@@ -204,7 +179,6 @@
|
||||
nameFieldView: nameFieldView,
|
||||
sectionOneFieldViews: sectionOneFieldViews,
|
||||
sectionTwoFieldViews: sectionTwoFieldViews,
|
||||
badgeListContainer: badgeListContainer,
|
||||
platformName: options.platform_name
|
||||
});
|
||||
|
||||
@@ -239,7 +213,6 @@
|
||||
accountSettingsModel: accountSettingsModel,
|
||||
accountPreferencesModel: accountPreferencesModel,
|
||||
learnerProfileView: learnerProfileView,
|
||||
badgeListContainer: badgeListContainer
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
|
||||
define(['backbone'], function(Backbone) {
|
||||
var BadgesModel = Backbone.Model.extend({});
|
||||
return BadgesModel;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -31,7 +31,6 @@ define(
|
||||
return new LearnerProfilePage({
|
||||
accounts_api_url: Helpers.USER_ACCOUNTS_API_URL,
|
||||
preferences_api_url: Helpers.USER_PREFERENCES_API_URL,
|
||||
badges_api_url: Helpers.BADGES_API_URL,
|
||||
own_profile: ownProfile,
|
||||
account_settings_page_url: Helpers.USER_ACCOUNTS_API_URL,
|
||||
country_options: Helpers.FIELD_OPTIONS,
|
||||
@@ -68,148 +67,6 @@ define(
|
||||
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView);
|
||||
});
|
||||
|
||||
it("doesn't show the mode toggle if badges are disabled", function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: false}),
|
||||
tabbedView = context.learnerProfileView.tabbedView,
|
||||
learnerProfileView = context.learnerProfileView;
|
||||
|
||||
LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView);
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
});
|
||||
|
||||
it("doesn't show the mode toggle if badges fail to fetch", function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: false}),
|
||||
tabbedView = context.learnerProfileView.tabbedView,
|
||||
learnerProfileView = context.learnerProfileView;
|
||||
|
||||
LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView);
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
});
|
||||
|
||||
it('renders the mode toggle if there are badges', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
tabbedView = context.learnerProfileView.tabbedView;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
|
||||
|
||||
LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView);
|
||||
});
|
||||
|
||||
it('renders the mode toggle if badges enabled but none exist', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
tabbedView = context.learnerProfileView.tabbedView;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges);
|
||||
|
||||
LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView);
|
||||
});
|
||||
|
||||
it('displays the badges when the accomplishments toggle is selected', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
learnerProfileView = context.learnerProfileView,
|
||||
tabbedView = learnerProfileView.tabbedView;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges);
|
||||
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
tabbedView.$el.find('[data-url="accomplishments"]').click();
|
||||
LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false);
|
||||
tabbedView.$el.find('[data-url="about_me"]').click();
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
});
|
||||
|
||||
it('displays a placeholder on the last page of badges', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
learnerProfileView = context.learnerProfileView,
|
||||
tabbedView = learnerProfileView.tabbedView;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges);
|
||||
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
tabbedView.$el.find('[data-url="accomplishments"]').click();
|
||||
LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true);
|
||||
tabbedView.$el.find('[data-url="about_me"]').click();
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
});
|
||||
|
||||
it('displays a placeholder when the accomplishments toggle is selected and no badges exist', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
learnerProfileView = context.learnerProfileView,
|
||||
tabbedView = learnerProfileView.tabbedView;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges);
|
||||
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
tabbedView.$el.find('[data-url="accomplishments"]').click();
|
||||
LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 0, true);
|
||||
tabbedView.$el.find('[data-url="about_me"]').click();
|
||||
LearnerProfileHelpers.expectBadgesHidden(learnerProfileView);
|
||||
});
|
||||
|
||||
it('shows a paginated list of badges', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
learnerProfileView = context.learnerProfileView,
|
||||
tabbedView = learnerProfileView.tabbedView;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
|
||||
|
||||
tabbedView.$el.find('[data-url="accomplishments"]').click();
|
||||
LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false);
|
||||
LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges);
|
||||
});
|
||||
|
||||
it('allows forward and backward navigation of badges', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
context = createProfilePage(true, {accomplishments_shared: true}),
|
||||
learnerProfileView = context.learnerProfileView,
|
||||
tabbedView = learnerProfileView.tabbedView,
|
||||
badgeListContainer = context.badgeListContainer;
|
||||
|
||||
AjaxHelpers.expectRequest(requests, 'POST', '/event');
|
||||
AjaxHelpers.respondWithError(requests, 404);
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
|
||||
|
||||
tabbedView.$el.find('[data-url="accomplishments"]').click();
|
||||
|
||||
badgeListContainer.$el.find('.next-page-link').click();
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges);
|
||||
LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges);
|
||||
|
||||
badgeListContainer.$el.find('.next-page-link').click();
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges);
|
||||
LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true);
|
||||
LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.thirdPageBadges);
|
||||
|
||||
badgeListContainer.$el.find('.previous-page-link').click();
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges);
|
||||
LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges);
|
||||
LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false);
|
||||
|
||||
badgeListContainer.$el.find('.previous-page-link').click();
|
||||
AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges);
|
||||
LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges);
|
||||
});
|
||||
|
||||
it('renders the limited profile for under 13 users', function() {
|
||||
var context = createProfilePage(
|
||||
true,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'URI',
|
||||
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
|
||||
'edx-ui-toolkit/js/pagination/paging-collection',
|
||||
'learner_profile/js/spec_helpers/helpers',
|
||||
'learner_profile/js/views/badge_list_container'
|
||||
],
|
||||
function(Backbone, $, _, URI, AjaxHelpers, PagingCollection, LearnerProfileHelpers, BadgeListContainer) {
|
||||
'use strict';
|
||||
|
||||
describe('edx.user.BadgeListContainer', function() {
|
||||
var view;
|
||||
|
||||
var createView = function(requests, pageNum, badgeListObject) {
|
||||
var BadgeCollection = PagingCollection.extend({
|
||||
queryParams: {
|
||||
currentPage: 'current_page'
|
||||
}
|
||||
});
|
||||
var badgeCollection = new BadgeCollection();
|
||||
var models = [];
|
||||
var badgeListContainer;
|
||||
var request;
|
||||
var path;
|
||||
badgeCollection.url = '/api/badges/v1/assertions/user/staff/';
|
||||
_.each(_.range(badgeListObject.count), function(idx) {
|
||||
models.push(LearnerProfileHelpers.makeBadge(idx));
|
||||
});
|
||||
badgeListObject.results = models; // eslint-disable-line no-param-reassign
|
||||
badgeCollection.setPage(pageNum);
|
||||
request = AjaxHelpers.currentRequest(requests);
|
||||
path = new URI(request.url).path();
|
||||
expect(path).toBe('/api/badges/v1/assertions/user/staff/');
|
||||
AjaxHelpers.respondWithJson(requests, badgeListObject);
|
||||
badgeListContainer = new BadgeListContainer({
|
||||
collection: badgeCollection
|
||||
|
||||
});
|
||||
badgeListContainer.render();
|
||||
return badgeListContainer;
|
||||
};
|
||||
|
||||
afterEach(function() {
|
||||
view.$el.remove();
|
||||
});
|
||||
|
||||
it('displays all badges', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
badges;
|
||||
view = createView(requests, 1, {
|
||||
count: 30,
|
||||
previous: '/arbitrary/url',
|
||||
num_pages: 3,
|
||||
next: null,
|
||||
start: 20,
|
||||
current_page: 1,
|
||||
results: []
|
||||
});
|
||||
badges = view.$el.find('div.badge-display');
|
||||
expect(badges.length).toBe(30);
|
||||
});
|
||||
|
||||
it('displays placeholder on last page', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
placeholder;
|
||||
view = createView(requests, 3, {
|
||||
count: 30,
|
||||
previous: '/arbitrary/url',
|
||||
num_pages: 3,
|
||||
next: null,
|
||||
start: 20,
|
||||
current_page: 3,
|
||||
results: []
|
||||
});
|
||||
placeholder = view.$el.find('span.accomplishment-placeholder');
|
||||
expect(placeholder.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not display placeholder on first page', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
placeholder;
|
||||
view = createView(requests, 1, {
|
||||
count: 30,
|
||||
previous: '/arbitrary/url',
|
||||
num_pages: 3,
|
||||
next: null,
|
||||
start: 0,
|
||||
current_page: 1,
|
||||
results: []
|
||||
});
|
||||
placeholder = view.$el.find('span.accomplishment-placeholder');
|
||||
expect(placeholder.length).toBe(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,81 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'edx-ui-toolkit/js/pagination/paging-collection',
|
||||
'learner_profile/js/spec_helpers/helpers',
|
||||
'learner_profile/js/views/badge_list_view'
|
||||
],
|
||||
function(Backbone, $, _, PagingCollection, LearnerProfileHelpers, BadgeListView) {
|
||||
'use strict';
|
||||
|
||||
describe('edx.user.BadgeListView', function() {
|
||||
var view;
|
||||
|
||||
var createView = function(badges, pages, page, hasNextPage) {
|
||||
var badgeCollection = new PagingCollection();
|
||||
var models = [];
|
||||
var badgeList;
|
||||
badgeCollection.url = '/api/badges/v1/assertions/user/staff/';
|
||||
_.each(badges, function(element) {
|
||||
models.push(new Backbone.Model(element));
|
||||
});
|
||||
badgeCollection.models = models;
|
||||
badgeCollection.length = badges.length;
|
||||
badgeCollection.currentPage = page;
|
||||
badgeCollection.totalPages = pages;
|
||||
badgeCollection.hasNextPage = function() {
|
||||
return hasNextPage;
|
||||
};
|
||||
badgeList = new BadgeListView({
|
||||
collection: badgeCollection
|
||||
|
||||
});
|
||||
return badgeList;
|
||||
};
|
||||
|
||||
afterEach(function() {
|
||||
view.$el.remove();
|
||||
});
|
||||
|
||||
it('there is a single row if there is only one badge', function() {
|
||||
var rows;
|
||||
view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 1, false);
|
||||
view.render();
|
||||
rows = view.$el.find('div.row');
|
||||
expect(rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('accomplishments placeholder is visible on a last page', function() {
|
||||
var placeholder;
|
||||
view = createView([LearnerProfileHelpers.makeBadge(1)], 2, 2, false);
|
||||
view.render();
|
||||
placeholder = view.$el.find('span.accomplishment-placeholder');
|
||||
expect(placeholder.length).toBe(1);
|
||||
});
|
||||
|
||||
it('accomplishments placeholder to be not visible on a first page', function() {
|
||||
var placeholder;
|
||||
view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 2, true);
|
||||
view.render();
|
||||
placeholder = view.$el.find('span.accomplishment-placeholder');
|
||||
expect(placeholder.length).toBe(0);
|
||||
});
|
||||
|
||||
it('badges are in two columns (checked by counting rows for a known number of badges)', function() {
|
||||
var badges = [];
|
||||
var placeholder;
|
||||
var rows;
|
||||
_.each(_.range(4), function(item) {
|
||||
badges.push(LearnerProfileHelpers.makeBadge(item));
|
||||
});
|
||||
view = createView(badges, 1, 2, true);
|
||||
view.render();
|
||||
placeholder = view.$el.find('span.accomplishment-placeholder');
|
||||
expect(placeholder.length).toBe(0);
|
||||
rows = view.$el.find('div.row');
|
||||
expect(rows.length).toBe(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,114 +0,0 @@
|
||||
define([
|
||||
'backbone', 'jquery', 'underscore',
|
||||
'learner_profile/js/spec_helpers/helpers',
|
||||
'learner_profile/js/views/badge_view'
|
||||
],
|
||||
function(Backbone, $, _, LearnerProfileHelpers, BadgeView) {
|
||||
'use strict';
|
||||
|
||||
describe('edx.user.BadgeView', function() {
|
||||
var view,
|
||||
badge,
|
||||
testBadgeNameIsDisplayed,
|
||||
testBadgeIconIsDisplayed;
|
||||
|
||||
var createView = function(ownProfile) {
|
||||
var options,
|
||||
testView;
|
||||
badge = LearnerProfileHelpers.makeBadge(1);
|
||||
options = {
|
||||
model: new Backbone.Model(badge),
|
||||
ownProfile: ownProfile,
|
||||
badgeMeta: {}
|
||||
};
|
||||
testView = new BadgeView(options);
|
||||
testView.render();
|
||||
$('body').append(testView.$el);
|
||||
testView.$el.show();
|
||||
expect(testView.$el.is(':visible')).toBe(true);
|
||||
return testView;
|
||||
};
|
||||
|
||||
afterEach(function() {
|
||||
view.$el.remove();
|
||||
$('.badges-modal').remove();
|
||||
});
|
||||
|
||||
it('profile of other has no share button', function() {
|
||||
view = createView(false);
|
||||
expect(view.context.ownProfile).toBeFalsy();
|
||||
expect(view.$el.find('button.share-button').length).toBe(0);
|
||||
});
|
||||
|
||||
it('own profile has share button', function() {
|
||||
view = createView(true);
|
||||
expect(view.context.ownProfile).toBeTruthy();
|
||||
expect(view.$el.find('button.share-button').length).toBe(1);
|
||||
});
|
||||
|
||||
it('click on share button calls createModal function', function() {
|
||||
var shareButton;
|
||||
view = createView(true);
|
||||
spyOn(view, 'createModal');
|
||||
view.delegateEvents();
|
||||
expect(view.context.ownProfile).toBeTruthy();
|
||||
shareButton = view.$el.find('button.share-button');
|
||||
expect(shareButton.length).toBe(1);
|
||||
expect(view.createModal).not.toHaveBeenCalled();
|
||||
shareButton.click();
|
||||
expect(view.createModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('click on share button calls shows the dialog', function(done) {
|
||||
var shareButton,
|
||||
$modalElement;
|
||||
view = createView(true);
|
||||
expect(view.context.ownProfile).toBeTruthy();
|
||||
shareButton = view.$el.find('button.share-button');
|
||||
expect(shareButton.length).toBe(1);
|
||||
$modalElement = $('.badges-modal');
|
||||
expect($modalElement.length).toBe(0);
|
||||
expect($modalElement.is(':visible')).toBeFalsy();
|
||||
shareButton.click();
|
||||
// Note: this element should have appeared in the dom during: shareButton.click();
|
||||
$modalElement = $('.badges-modal');
|
||||
jasmine.waitUntil(function() {
|
||||
return $modalElement.is(':visible');
|
||||
}).always(done);
|
||||
});
|
||||
|
||||
testBadgeNameIsDisplayed = function(ownProfile) {
|
||||
var badgeDiv;
|
||||
view = createView(ownProfile);
|
||||
badgeDiv = view.$el.find('.badge-name');
|
||||
expect(badgeDiv.length).toBeTruthy();
|
||||
expect(badgeDiv.is(':visible')).toBe(true);
|
||||
expect(_.count(badgeDiv.html(), badge.badge_class.display_name)).toBeTruthy();
|
||||
};
|
||||
|
||||
it('test badge name is displayed for own profile', function() {
|
||||
testBadgeNameIsDisplayed(true);
|
||||
});
|
||||
|
||||
it('test badge name is displayed for other profile', function() {
|
||||
testBadgeNameIsDisplayed(false);
|
||||
});
|
||||
|
||||
testBadgeIconIsDisplayed = function(ownProfile) {
|
||||
var badgeImg;
|
||||
view = createView(ownProfile);
|
||||
badgeImg = view.$el.find('img.badge');
|
||||
expect(badgeImg.length).toBe(1);
|
||||
expect(badgeImg.attr('src')).toEqual(badge.image_url);
|
||||
};
|
||||
|
||||
it('test badge icon is displayed for own profile', function() {
|
||||
testBadgeIconIsDisplayed(true);
|
||||
});
|
||||
|
||||
it('test badge icon is displayed for other profile', function() {
|
||||
testBadgeIconIsDisplayed(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -15,13 +15,12 @@ define(
|
||||
'js/student_account/models/user_preferences_model',
|
||||
'learner_profile/js/views/learner_profile_fields',
|
||||
'learner_profile/js/views/learner_profile_view',
|
||||
'learner_profile/js/views/badge_list_container',
|
||||
'js/student_account/views/account_settings_fields',
|
||||
'js/views/message_banner'
|
||||
],
|
||||
function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers,
|
||||
FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
|
||||
BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) {
|
||||
AccountSettingsFieldViews, MessageBannerView) {
|
||||
'use strict';
|
||||
|
||||
describe('edx.user.LearnerProfileView', function() {
|
||||
@@ -132,15 +131,6 @@ define(
|
||||
})
|
||||
];
|
||||
|
||||
var badgeCollection = new PagingCollection();
|
||||
badgeCollection.url = Helpers.BADGES_API_URL;
|
||||
|
||||
var badgeListContainer = new BadgeListContainer({
|
||||
attributes: {class: 'badge-set-display'},
|
||||
collection: badgeCollection,
|
||||
find_courses_url: Helpers.FIND_COURSES_URL
|
||||
});
|
||||
|
||||
return new LearnerProfileView(
|
||||
{
|
||||
el: $('.wrapper-profile'),
|
||||
@@ -154,7 +144,6 @@ define(
|
||||
profileImageFieldView: profileImageFieldView,
|
||||
sectionOneFieldViews: sectionOneFieldViews,
|
||||
sectionTwoFieldViews: sectionTwoFieldViews,
|
||||
badgeListContainer: badgeListContainer
|
||||
});
|
||||
};
|
||||
|
||||
@@ -225,16 +214,5 @@ define(
|
||||
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
|
||||
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true);
|
||||
});
|
||||
|
||||
it("renders an error if the badges can't be fetched", function() {
|
||||
var learnerProfileView = createLearnerProfileView(false, 'all_users', true);
|
||||
learnerProfileView.options.accountSettingsModel.set({accomplishments_shared: true});
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
|
||||
learnerProfileView.render();
|
||||
|
||||
LearnerProfileHelpers.breakBadgeLoading(learnerProfileView, requests);
|
||||
LearnerProfileHelpers.expectBadgeLoadingErrorIsRendered(learnerProfileView);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
define(
|
||||
[
|
||||
'backbone', 'jquery', 'underscore', 'moment',
|
||||
'js/spec/student_account/helpers',
|
||||
'learner_profile/js/spec_helpers/helpers',
|
||||
'learner_profile/js/views/share_modal_view',
|
||||
'jquery.simulate'
|
||||
],
|
||||
function(Backbone, $, _, Moment, Helpers, LearnerProfileHelpers, ShareModalView) {
|
||||
'use strict';
|
||||
|
||||
describe('edx.user.ShareModalView', function() {
|
||||
var keys = $.simulate.keyCode;
|
||||
|
||||
var view;
|
||||
|
||||
var createModalView = function() {
|
||||
var badge = LearnerProfileHelpers.makeBadge(1);
|
||||
var context = _.extend(badge, {
|
||||
created: new Moment(badge.created),
|
||||
ownProfile: true,
|
||||
badgeMeta: {}
|
||||
});
|
||||
return new ShareModalView({
|
||||
model: new Backbone.Model(context),
|
||||
shareButton: $('<button/>')
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
view = createModalView();
|
||||
// Attach view to document, otherwise click won't work
|
||||
view.render();
|
||||
$('body').append(view.$el);
|
||||
view.$el.show();
|
||||
expect(view.$el.is(':visible')).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
view.$el.remove();
|
||||
});
|
||||
|
||||
it('modal view closes on escape', function() {
|
||||
spyOn(view, 'close');
|
||||
view.delegateEvents();
|
||||
expect(view.close).not.toHaveBeenCalled();
|
||||
$(view.$el).simulate('keydown', {keyCode: keys.ESCAPE});
|
||||
expect(view.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('modal view closes click on close', function() {
|
||||
var $closeButton;
|
||||
spyOn(view, 'close');
|
||||
view.delegateEvents();
|
||||
$closeButton = view.$el.find('button.close');
|
||||
expect($closeButton.length).toBe(1);
|
||||
expect(view.close).not.toHaveBeenCalled();
|
||||
$closeButton.trigger('click');
|
||||
expect(view.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -123,137 +123,11 @@ define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'
|
||||
expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true);
|
||||
};
|
||||
|
||||
var expectBadgesDisplayed = function(learnerProfileView, length, lastPage) {
|
||||
var $badgeListingView = $('#tabpanel-accomplishments'),
|
||||
updatedLength = length,
|
||||
placeholder;
|
||||
expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(true);
|
||||
expect($badgeListingView.hasClass('is-hidden')).toBe(false);
|
||||
if (lastPage) {
|
||||
updatedLength += 1;
|
||||
placeholder = $badgeListingView.find('.find-course');
|
||||
expect(placeholder.length).toBe(1);
|
||||
expect(placeholder.attr('href')).toBe('/courses/');
|
||||
}
|
||||
expect($badgeListingView.find('.badge-display').length).toBe(updatedLength);
|
||||
};
|
||||
|
||||
var expectBadgesHidden = function() {
|
||||
var $accomplishmentsTab = $('#tabpanel-accomplishments');
|
||||
if ($accomplishmentsTab.length) {
|
||||
// Nonexistence counts as hidden.
|
||||
expect($('#tabpanel-accomplishments').hasClass('is-hidden')).toBe(true);
|
||||
}
|
||||
expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(false);
|
||||
};
|
||||
|
||||
var expectPage = function(learnerProfileView, pageData) {
|
||||
var $badgeListContainer = $('#tabpanel-accomplishments');
|
||||
var index = $badgeListContainer.find('span.search-count').text().trim();
|
||||
expect(index).toBe('Showing ' + (pageData.start + 1) + '-' + (pageData.start + pageData.results.length)
|
||||
+ ' out of ' + pageData.count + ' total');
|
||||
expect($badgeListContainer.find('.current-page').text()).toBe('' + pageData.current_page);
|
||||
_.each(pageData.results, function(badge) {
|
||||
expect($('.badge-display:contains(' + badge.badge_class.display_name + ')').length).toBe(1);
|
||||
});
|
||||
};
|
||||
|
||||
var expectBadgeLoadingErrorIsRendered = function() {
|
||||
var errorMessage = $('.badge-set-display').text();
|
||||
expect(errorMessage).toBe(
|
||||
'Your request could not be completed. Reload the page and try again. If the issue persists, click the '
|
||||
+ 'Help tab to report the problem.'
|
||||
);
|
||||
};
|
||||
|
||||
var breakBadgeLoading = function(learnerProfileView, requests) {
|
||||
var request = AjaxHelpers.currentRequest(requests);
|
||||
var path = new URI(request.url).path();
|
||||
expect(path).toBe('/api/badges/v1/assertions/user/student/');
|
||||
AjaxHelpers.respondWithError(requests, 500);
|
||||
};
|
||||
|
||||
var firstPageBadges = {
|
||||
count: 30,
|
||||
previous: null,
|
||||
next: '/arbitrary/url',
|
||||
num_pages: 3,
|
||||
start: 0,
|
||||
current_page: 1,
|
||||
results: []
|
||||
};
|
||||
|
||||
var secondPageBadges = {
|
||||
count: 30,
|
||||
previous: '/arbitrary/url',
|
||||
next: '/arbitrary/url',
|
||||
num_pages: 3,
|
||||
start: 10,
|
||||
current_page: 2,
|
||||
results: []
|
||||
};
|
||||
|
||||
var thirdPageBadges = {
|
||||
count: 30,
|
||||
previous: '/arbitrary/url',
|
||||
num_pages: 3,
|
||||
next: null,
|
||||
start: 20,
|
||||
current_page: 3,
|
||||
results: []
|
||||
};
|
||||
|
||||
var emptyBadges = {
|
||||
count: 0,
|
||||
previous: null,
|
||||
num_pages: 1,
|
||||
results: []
|
||||
};
|
||||
|
||||
function makeBadge(num) {
|
||||
return {
|
||||
badge_class: {
|
||||
slug: 'test_slug_' + num,
|
||||
issuing_component: 'test_component',
|
||||
display_name: 'Test Badge ' + num,
|
||||
course_id: null,
|
||||
description: "Yay! It's a test badge.",
|
||||
criteria: 'https://example.com/syllabus',
|
||||
image_url: 'http://localhost:8000/media/badge_classes/test_lMB9bRw.png'
|
||||
},
|
||||
image_url: 'http://example.com/image.png',
|
||||
assertion_url: 'http://example.com/example.json',
|
||||
created_at: '2015-12-03T16:25:57.676113Z'
|
||||
};
|
||||
}
|
||||
|
||||
_.each(_.range(0, 10), function(i) {
|
||||
firstPageBadges.results.push(makeBadge(i));
|
||||
});
|
||||
|
||||
_.each(_.range(10, 20), function(i) {
|
||||
secondPageBadges.results.push(makeBadge(i));
|
||||
});
|
||||
|
||||
_.each(_.range(20, 30), function(i) {
|
||||
thirdPageBadges.results.push(makeBadge(i));
|
||||
});
|
||||
|
||||
return {
|
||||
expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
|
||||
expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
|
||||
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered,
|
||||
expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined,
|
||||
expectTabbedViewToBeShown: expectTabbedViewToBeShown,
|
||||
expectBadgesDisplayed: expectBadgesDisplayed,
|
||||
expectBadgesHidden: expectBadgesHidden,
|
||||
expectBadgeLoadingErrorIsRendered: expectBadgeLoadingErrorIsRendered,
|
||||
breakBadgeLoading: breakBadgeLoading,
|
||||
firstPageBadges: firstPageBadges,
|
||||
secondPageBadges: secondPageBadges,
|
||||
thirdPageBadges: thirdPageBadges,
|
||||
emptyBadges: emptyBadges,
|
||||
expectPage: expectPage,
|
||||
makeBadge: makeBadge
|
||||
expectTabbedViewToBeShown: expectTabbedViewToBeShown
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
(function(define) {
|
||||
'use strict';
|
||||
|
||||
define(
|
||||
[
|
||||
'gettext', 'jquery', 'underscore', 'common/js/components/views/paginated_view',
|
||||
'learner_profile/js/views/badge_view', 'learner_profile/js/views/badge_list_view',
|
||||
'text!learner_profile/templates/badge_list.underscore'
|
||||
],
|
||||
function(gettext, $, _, PaginatedView, BadgeView, BadgeListView, BadgeListTemplate) {
|
||||
var BadgeListContainer = PaginatedView.extend({
|
||||
type: 'badge',
|
||||
|
||||
itemViewClass: BadgeView,
|
||||
|
||||
listViewClass: BadgeListView,
|
||||
|
||||
viewTemplate: BadgeListTemplate,
|
||||
|
||||
isZeroIndexed: true,
|
||||
|
||||
paginationLabel: gettext('Accomplishments Pagination'),
|
||||
|
||||
initialize: function(options) {
|
||||
BadgeListContainer.__super__.initialize.call(this, options);
|
||||
this.listView.find_courses_url = options.find_courses_url;
|
||||
this.listView.badgeMeta = options.badgeMeta;
|
||||
this.listView.ownProfile = options.ownProfile;
|
||||
}
|
||||
});
|
||||
|
||||
return BadgeListContainer;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -1,65 +0,0 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
|
||||
define([
|
||||
'gettext',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'common/js/components/views/list',
|
||||
'learner_profile/js/views/badge_view',
|
||||
'text!learner_profile/templates/badge_placeholder.underscore'
|
||||
],
|
||||
function(gettext, $, _, HtmlUtils, ListView, BadgeView, badgePlaceholder) {
|
||||
var BadgeListView = ListView.extend({
|
||||
tagName: 'div',
|
||||
|
||||
template: HtmlUtils.template(badgePlaceholder),
|
||||
|
||||
renderCollection: function() {
|
||||
var self = this,
|
||||
$row;
|
||||
|
||||
this.$el.empty();
|
||||
|
||||
// Split into two columns.
|
||||
this.collection.each(function(badge, index) {
|
||||
var $item;
|
||||
if (index % 2 === 0) {
|
||||
$row = $('<div class="row">');
|
||||
this.$el.append($row);
|
||||
}
|
||||
$item = new BadgeView({
|
||||
model: badge,
|
||||
badgeMeta: this.badgeMeta,
|
||||
ownProfile: this.ownProfile
|
||||
}).render().el;
|
||||
|
||||
if ($row) {
|
||||
$row.append($item);
|
||||
}
|
||||
|
||||
this.itemViews.push($item);
|
||||
}, this);
|
||||
// Placeholder must always be at the end, and may need a new row.
|
||||
if (!this.collection.hasNextPage()) {
|
||||
// find_courses_url set by BadgeListContainer during initialization.
|
||||
if (this.collection.length % 2 === 0) {
|
||||
$row = $('<div class="row">');
|
||||
this.$el.append($row);
|
||||
}
|
||||
|
||||
if ($row) {
|
||||
HtmlUtils.append(
|
||||
$row,
|
||||
this.template({find_courses_url: self.find_courses_url})
|
||||
);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return BadgeListView;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -1,47 +0,0 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
|
||||
define(
|
||||
[
|
||||
'gettext', 'jquery', 'underscore', 'backbone', 'moment',
|
||||
'text!learner_profile/templates/badge.underscore',
|
||||
'learner_profile/js/views/share_modal_view',
|
||||
'edx-ui-toolkit/js/utils/html-utils'
|
||||
],
|
||||
function(gettext, $, _, Backbone, Moment, badgeTemplate, ShareModalView, HtmlUtils) {
|
||||
var BadgeView = Backbone.View.extend({
|
||||
initialize: function(options) {
|
||||
this.options = _.extend({}, options);
|
||||
this.context = _.extend(this.options.model.toJSON(), {
|
||||
created: new Moment(this.options.model.toJSON().created),
|
||||
ownProfile: options.ownProfile,
|
||||
badgeMeta: options.badgeMeta
|
||||
});
|
||||
},
|
||||
attributes: {
|
||||
class: 'badge-display'
|
||||
},
|
||||
template: _.template(badgeTemplate),
|
||||
events: {
|
||||
'click .share-button': 'createModal'
|
||||
},
|
||||
createModal: function() {
|
||||
var modal = new ShareModalView({
|
||||
model: new Backbone.Model(this.context),
|
||||
shareButton: this.shareButton
|
||||
});
|
||||
modal.$el.hide();
|
||||
modal.render();
|
||||
$('body').append(modal.$el);
|
||||
modal.$el.fadeIn('short', 'swing', _.bind(modal.ready, modal));
|
||||
},
|
||||
render: function() {
|
||||
this.$el.html(HtmlUtils.HTML(this.template(this.context)).toString());
|
||||
this.shareButton = this.$el.find('.share-button');
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return BadgeView;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -59,30 +59,12 @@
|
||||
$('.wrapper-profile-section-container-one').removeClass('is-hidden');
|
||||
$('.wrapper-profile-section-container-two').removeClass('is-hidden');
|
||||
|
||||
// Only show accomplishments if this is a full profile
|
||||
|
||||
if (this.showFullProfile()) {
|
||||
$('.learner-achievements').removeClass('is-hidden');
|
||||
} else {
|
||||
$('.learner-achievements').addClass('is-hidden');
|
||||
}
|
||||
|
||||
if (this.showFullProfile() && (this.options.accountSettingsModel.get('accomplishments_shared'))) {
|
||||
tabs = [
|
||||
{view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'},
|
||||
{
|
||||
view: this.options.badgeListContainer,
|
||||
title: gettext('Accomplishments'),
|
||||
url: 'accomplishments'
|
||||
}
|
||||
{view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'}
|
||||
];
|
||||
|
||||
// Build the accomplishments Tab and fill with data
|
||||
this.options.badgeListContainer.collection.fetch().done(function() {
|
||||
self.options.badgeListContainer.render();
|
||||
}).error(function() {
|
||||
self.options.badgeListContainer.renderError();
|
||||
});
|
||||
|
||||
this.tabbedView = new TabbedView({
|
||||
tabs: tabs,
|
||||
router: this.router,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
|
||||
define(
|
||||
[
|
||||
'gettext', 'jquery', 'underscore', 'backbone', 'moment',
|
||||
'text!learner_profile/templates/share_modal.underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils'
|
||||
],
|
||||
function(gettext, $, _, Backbone, Moment, badgeModalTemplate, HtmlUtils) {
|
||||
var ShareModalView = Backbone.View.extend({
|
||||
attributes: {
|
||||
class: 'badges-overlay'
|
||||
},
|
||||
template: _.template(badgeModalTemplate),
|
||||
events: {
|
||||
'click .badges-modal': function(event) { event.stopPropagation(); },
|
||||
'click .badges-modal .close': 'close',
|
||||
'click .badges-overlay': 'close',
|
||||
keydown: 'keyAction',
|
||||
'focus .focusguard-start': 'focusGuardStart',
|
||||
'focus .focusguard-end': 'focusGuardEnd'
|
||||
},
|
||||
initialize: function(options) {
|
||||
this.options = _.extend({}, options);
|
||||
},
|
||||
focusGuardStart: function() {
|
||||
// Should only be selected directly if shift-tabbing from the start, so grab last item.
|
||||
this.$el.find('a').last().focus();
|
||||
},
|
||||
focusGuardEnd: function() {
|
||||
this.$el.find('.badges-modal').focus();
|
||||
},
|
||||
close: function() {
|
||||
this.$el.fadeOut('short', 'swing', _.bind(this.remove, this));
|
||||
this.options.shareButton.focus();
|
||||
},
|
||||
keyAction: function(event) {
|
||||
if (event.keyCode === $.ui.keyCode.ESCAPE) {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
ready: function() {
|
||||
// Focusing on the modal background directly doesn't work, probably due
|
||||
// to its positioning.
|
||||
this.$el.find('.badges-modal').focus();
|
||||
},
|
||||
render: function() {
|
||||
this.$el.html(HtmlUtils.HTML(this.template(this.model.toJSON())).toString());
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return ShareModalView;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -1,30 +0,0 @@
|
||||
<div class="badge-image-container">
|
||||
<img class="badge" src="<%- image_url %>" alt=""/>
|
||||
</div>
|
||||
<div class="badge-details">
|
||||
<div class="badge-name"><%- badge_class.display_name %></div>
|
||||
<p class="badge-description"><%- badge_class.description %></p>
|
||||
<% if (ownProfile) { %>
|
||||
<button class="share-button">
|
||||
<div class="share-icon-container">
|
||||
<img class="icon icon-mozillaopenbadges" src="<%- badgeMeta.badges_icon %>" alt="<%-
|
||||
interpolate(
|
||||
// Translators: display_name is the name of an OpenBadges award.
|
||||
gettext('Share your "%(display_name)s" award'),
|
||||
{'display_name': badge_class.display_name},
|
||||
true
|
||||
)%>">
|
||||
</div>
|
||||
<div class="share-prefix" aria-hidden="true"><%- gettext("Share") %></div>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="badge-date-stamp">
|
||||
<%-
|
||||
interpolate(
|
||||
// Translators: Date stamp for earned badges. Example: Earned December 3, 2015.
|
||||
gettext('Earned %(created)s.'),
|
||||
{created: created.format('LL')},
|
||||
true
|
||||
)
|
||||
%></div>
|
||||
</div>
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="sr-is-focusable sr-<%- type %>-view" tabindex="-1"></div>
|
||||
<div class="<%- type %>-paging-header"></div>
|
||||
<div class="<%- type %>-list cards-list"></div>
|
||||
<div class="<%- type %>-paging-footer"></div>
|
||||
@@ -1,10 +0,0 @@
|
||||
<div class="badge-display badge-placeholder">
|
||||
<div class="badge-image-container">
|
||||
<span class="accomplishment-placeholder" aria-hidden="true">
|
||||
</div>
|
||||
<div class="badge-details">
|
||||
<div class="badge-name"><%- gettext("What's Your Next Accomplishment?") %></div>
|
||||
<p class="badge-description"><%- gettext('Start working toward your next learning goal.') %></p>
|
||||
<a class="find-course" href="<%- find_courses_url %>"><span class="find-button-container"><%- gettext('Find a course') %></span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,41 +0,0 @@
|
||||
<div class="focusguard focusguard-start" tabindex="0"></div>
|
||||
<div class="badges-modal" tabindex="0">
|
||||
<button class="close"><span class="fa fa-close" aria-hidden="true"></span><span class="sr"><%- gettext("Close") %></span></button>
|
||||
<h1 class="modal-header"><%- gettext("Share on Mozilla Backpack") %></h1>
|
||||
<p class="explanation"><%- gettext("To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.") %>
|
||||
</p>
|
||||
<hr class="modal-hr"/>
|
||||
<img class="backpack-logo" src="<%- badgeMeta.badges_logo %>" alt="">
|
||||
<ol class="badges-steps">
|
||||
<li class="step">
|
||||
<%= edx.HtmlUtils.interpolateHtml(
|
||||
gettext("Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account"),
|
||||
{
|
||||
link_start: edx.HtmlUtils.HTML('<a href="https://backpack.openbadges.org/" rel="noopener" target="_blank">'),
|
||||
link_end: edx.HtmlUtils.HTML('</a>')
|
||||
}
|
||||
)
|
||||
%>
|
||||
</li>
|
||||
|
||||
<li class="step">
|
||||
<%= edx.HtmlUtils.interpolateHtml(
|
||||
gettext("{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack."),
|
||||
{
|
||||
download_link_start: edx.HtmlUtils.joinHtml(
|
||||
edx.HtmlUtils.HTML('<a class="badge-link" href="'),
|
||||
image_url,
|
||||
edx.HtmlUtils.HTML('" rel="noopener" target="_blank">'),
|
||||
),
|
||||
link_end: edx.HtmlUtils.HTML('</a>'),
|
||||
upload_link_start: edx.HtmlUtils.HTML('<a href="https://backpack.openbadges.org/backpack/add" rel="noopener" target="_blank">')
|
||||
}
|
||||
)
|
||||
%>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="image-container">
|
||||
<img class="badges-backpack-example" src="<%- badgeMeta.backpack_ui_img %>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="focusguard focusguard-end" tabindex="0"></div>
|
||||
@@ -11,7 +11,6 @@ from django.urls import reverse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django_countries import countries
|
||||
|
||||
from lms.djangoapps.badges.utils import badges_enabled
|
||||
from common.djangoapps.edxmako.shortcuts import marketing_link
|
||||
from openedx.core.djangoapps.credentials.utils import get_credentials_records_url
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
@@ -105,8 +104,6 @@ def learner_profile_context(request, profile_username, user_is_staff):
|
||||
'country_options': list(countries),
|
||||
'find_courses_url': marketing_link('COURSES'),
|
||||
'language_options': settings.ALL_LANGUAGES,
|
||||
'badges_logo': staticfiles_storage.url('certificates/images/backpack-logo.png'),
|
||||
'badges_icon': staticfiles_storage.url('certificates/images/ico-mozillaopenbadges.png'),
|
||||
'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'),
|
||||
'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
|
||||
'social_platforms': settings.SOCIAL_PLATFORMS,
|
||||
@@ -128,7 +125,4 @@ def learner_profile_context(request, profile_username, user_is_staff):
|
||||
)
|
||||
context['achievements_fragment'] = achievements_fragment
|
||||
|
||||
if badges_enabled():
|
||||
context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username})
|
||||
|
||||
return context
|
||||
|
||||
@@ -99,9 +99,6 @@ module.exports = {
|
||||
'../openedx/features/course_search/static/course_search/js/views/dashboard_search_results_view.js'
|
||||
),
|
||||
path.resolve(__dirname, '../openedx/features/course_search/static/course_search/js/views/search_results_view.js'),
|
||||
path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js'),
|
||||
path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js'),
|
||||
path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_view.js'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js'
|
||||
|
||||
@@ -619,14 +619,6 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
# Ensure that courses imported from XML keep their image
|
||||
default="images_course_image.jpg"
|
||||
)
|
||||
issue_badges = Boolean(
|
||||
display_name=_("Issue Open Badges"),
|
||||
help=_(
|
||||
"Issue Open Badges badges for this course. Badges are generated when certificates are created."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default=True
|
||||
)
|
||||
## Course level Certificate Name overrides.
|
||||
cert_name_short = String(
|
||||
help=_(
|
||||
|
||||
Reference in New Issue
Block a user