feat!: Removed Badges App
fix: restored badges handlers feat: remove FE code for badges fix: resolved failing tests fix: removed test case for badges app fix: unused import error fix: Response Field Count fix: shareable account response length fix: resolved PR comments fix: revert settings override feat!: Removed Badges App fix: restored badges handlers feat: remove FE code for badges fix: resolved failing tests fix: removed test case for badges app fix: unused import error fix: Response Field Count fix: shareable account response length fix: revert subscription badge
This commit is contained in:
committed by
Zaeema Anwar
parent
92731be0dc
commit
0b3e4d73b6
@@ -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')),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user