From 17ec12c65076400b597f8a244823ceaf4a123211 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Wed, 23 Mar 2016 18:55:35 +0000 Subject: [PATCH] Address platform final review notes. --- common/djangoapps/student/models.py | 3 +- .../common/js/spec_helpers/ajax_helpers.js | 3 +- .../acceptance/pages/lms/learner_profile.py | 52 ++ .../tests/lms/test_learner_profile.py | 19 + .../db_fixtures/certificates_web_view.json | 499 ++++++++++++++++++ lms/djangoapps/badges/admin.py | 1 + lms/djangoapps/badges/api/tests.py | 81 ++- lms/djangoapps/badges/api/views.py | 13 +- lms/djangoapps/badges/backends/badgr.py | 3 + .../badges/backends/tests/dummy_backend.py | 13 + .../backends/tests/test_badgr_backend.py | 24 +- lms/djangoapps/badges/events/course_meta.py | 3 + .../badges/events/tests/test_course_meta.py | 82 +-- .../0002_data__migrate_assertions.py | 10 +- lms/djangoapps/badges/models.py | 27 +- lms/djangoapps/badges/utils.py | 22 +- lms/djangoapps/certificates/views/webview.py | 3 +- lms/djangoapps/lms_xblock/runtime.py | 3 +- lms/djangoapps/student_profile/views.py | 3 +- lms/envs/bok_choy.py | 5 + .../student_profile/views/share_modal_view.js | 3 +- lms/static/sass/views/_learner-profile.scss | 8 + .../student_profile/badge_list.underscore | 2 +- .../user_api/accounts/serializers.py | 3 +- .../uploads/course_complete_badges/honor.png | Bin 0 -> 13316 bytes 25 files changed, 729 insertions(+), 156 deletions(-) create mode 100644 lms/djangoapps/badges/backends/tests/dummy_backend.py create mode 100644 test_root/uploads/course_complete_badges/honor.png diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 765a75b3a0..cf5fa7bac0 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -46,6 +46,7 @@ from simple_history.models import HistoricalRecords from track import contexts from xmodule_django.models import CourseKeyField, NoneToEmptyManager +from lms.djangoapps.badges.utils import badges_enabled from certificates.models import GeneratedCertificate from course_modes.models import CourseMode from enrollment.api import _default_course_mode @@ -1213,7 +1214,7 @@ class CourseEnrollment(models.Model): # User is allowed to enroll if they've reached this point. enrollment = cls.get_or_create_enrollment(user, course_key) enrollment.update_enrollment(is_active=True, mode=mode) - if settings.FEATURES.get("ENABLE_OPENBADGES"): + if badges_enabled(): from lms.djangoapps.badges.events.course_meta import award_enrollment_badge award_enrollment_badge(user) diff --git a/common/static/common/js/spec_helpers/ajax_helpers.js b/common/static/common/js/spec_helpers/ajax_helpers.js index 5cf3f0cb1b..056328e07e 100644 --- a/common/static/common/js/spec_helpers/ajax_helpers.js +++ b/common/static/common/js/spec_helpers/ajax_helpers.js @@ -73,7 +73,8 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { expect(request.url).toEqual(url); expect(request.method).toEqual(method); if (typeof body === 'undefined') { - // The contents if this call may not be germane to the current test. + // The body of the request may not be germane to the current test-- like some call by a library, + // so allow it to be ignored. return; } expect(request.requestBody).toEqual(body); diff --git a/common/test/acceptance/pages/lms/learner_profile.py b/common/test/acceptance/pages/lms/learner_profile.py index 2b22bcbd3e..16177cfefb 100644 --- a/common/test/acceptance/pages/lms/learner_profile.py +++ b/common/test/acceptance/pages/lms/learner_profile.py @@ -1,6 +1,8 @@ """ Bok-Choy PageObject class for learner profile page. """ +from bok_choy.query import BrowserQuery + from . import BASE_URL from bok_choy.page_object import PageObject from .fields import FieldsMixin @@ -16,6 +18,35 @@ FIELD_ICONS = { } +class Badge(PageObject): + """ + Represents a single badge displayed on the learner profile page. + """ + url = None + + def __init__(self, element, browser): + self.full_view = browser + # Element API is similar to browser API, should allow subqueries. + super(Badge, self).__init__(element) + + def is_browser_on_page(self): + return self.q(css=".badge-details").visible + + def modal_displayed(self): + """ + Verifies that the share modal is diplayed. + """ + # The modal is on the page at large, and not a subelement of the badge div. + return BrowserQuery(self.full_view, css=".badges-modal").visible + + def display_modal(self): + """ + Click the share button to display the sharing modal for the badge. + """ + self.q(css=".share-button").click() + EmptyPromise(self.modal_displayed, "Share modal displayed").fulfill() + + class LearnerProfilePage(FieldsMixin, PageObject): """ PageObject methods for Learning Profile Page. @@ -58,6 +89,27 @@ class LearnerProfilePage(FieldsMixin, PageObject): """ return 'all_users' if self.q(css=PROFILE_VISIBILITY_SELECTOR.format('all_users')).selected else 'private' + def accomplishments_available(self): + """ + Verify that the accomplishments tab is available. + """ + return self.q(css="button[data-url='accomplishments']").visible + + def display_accomplishments(self): + """ + Click the accomplishments tab and wait for the accomplishments to load. + """ + EmptyPromise(self.accomplishments_available, "Accomplishments tab is displayed").fulfill() + self.q(css="button[data-url='accomplishments']").click() + self.wait_for_element_visibility(".badge-list", "Badge list displayed") + + @property + def badges(self): + """ + Get all currently listed badges. + """ + return [Badge(element, self.browser) for element in self.q(css=".badge-display:not(.badge-placeholder)")] + @privacy.setter def privacy(self, privacy): """ diff --git a/common/test/acceptance/tests/lms/test_learner_profile.py b/common/test/acceptance/tests/lms/test_learner_profile.py index 01d0159c99..6c69662f7f 100644 --- a/common/test/acceptance/tests/lms/test_learner_profile.py +++ b/common/test/acceptance/tests/lms/test_learner_profile.py @@ -800,3 +800,22 @@ class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest): }) profile_page.a11y_audit.check_for_accessibility_errors() + + def test_badges_accessibility(self): + """ + Test the accessibility of the badge listings and sharing modal. + """ + username = 'testcert' + AutoAuthPage(self.browser, username=username).visit() + profile_page = self.visit_profile_page(username) + + profile_page.a11y_audit.config.set_rules({ + "ignore": [ + 'skip-link', # TODO: AC-179 + 'link-href', # TODO: AC-231 + ], + }) + profile_page.display_accomplishments() + profile_page.a11y_audit.check_for_accessibility_errors() + profile_page.badges[0].display_modal() + profile_page.a11y_audit.check_for_accessibility_errors() diff --git a/common/test/db_fixtures/certificates_web_view.json b/common/test/db_fixtures/certificates_web_view.json index fe7bd48829..3ef1912536 100644 --- a/common/test/db_fixtures/certificates_web_view.json +++ b/common/test/db_fixtures/certificates_web_view.json @@ -110,5 +110,504 @@ "company_identifier": "7nTFLiuDkkQkdELSpruCwD4F6jzqtTFsx3PfJUIT2qHqXRLG1", "trk_partner_name": "edx" } + }, + { + "fields": { + "display_name": "Test Badge", + "description": "Yay! It's a test badge.", + "image": "badge_classes/test_Nln8nhu.png", + "issuing_component": "test_component", + "mode": "honor", + "criteria": "https://example.com/syllabus", + "course_id": null, + "slug": "test_slug_0_0768205214811" + }, + "model": "badges.badgeclass", + "pk": 1 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.401Z", + "modified": "2016-03-22T15:35:21.403Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 1 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.412Z", + "modified": "2016-03-22T15:35:21.413Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 2 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.422Z", + "modified": "2016-03-22T15:35:21.423Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 3 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.437Z", + "modified": "2016-03-22T15:35:21.437Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 4 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.444Z", + "modified": "2016-03-22T15:35:21.445Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 5 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.451Z", + "modified": "2016-03-22T15:35:21.451Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 6 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.459Z", + "modified": "2016-03-22T15:35:21.459Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 7 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.467Z", + "modified": "2016-03-22T15:35:21.467Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 8 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.475Z", + "modified": "2016-03-22T15:35:21.476Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 9 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.488Z", + "modified": "2016-03-22T15:35:21.489Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 10 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.495Z", + "modified": "2016-03-22T15:35:21.496Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 11 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.503Z", + "modified": "2016-03-22T15:35:21.504Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 12 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.511Z", + "modified": "2016-03-22T15:35:21.512Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 13 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.519Z", + "modified": "2016-03-22T15:35:21.519Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 14 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.532Z", + "modified": "2016-03-22T15:35:21.534Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 15 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.542Z", + "modified": "2016-03-22T15:35:21.544Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 16 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.551Z", + "modified": "2016-03-22T15:35:21.552Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 17 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.559Z", + "modified": "2016-03-22T15:35:21.560Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 18 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.566Z", + "modified": "2016-03-22T15:35:21.566Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 19 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.573Z", + "modified": "2016-03-22T15:35:21.574Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 20 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.592Z", + "modified": "2016-03-22T15:35:21.592Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 21 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.600Z", + "modified": "2016-03-22T15:35:21.600Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 22 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.607Z", + "modified": "2016-03-22T15:35:21.608Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 23 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.617Z", + "modified": "2016-03-22T15:35:21.618Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 24 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.624Z", + "modified": "2016-03-22T15:35:21.625Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 25 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.632Z", + "modified": "2016-03-22T15:35:21.632Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 26 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.645Z", + "modified": "2016-03-22T15:35:21.646Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 27 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.653Z", + "modified": "2016-03-22T15:35:21.653Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 28 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.659Z", + "modified": "2016-03-22T15:35:21.659Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 29 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.667Z", + "modified": "2016-03-22T15:35:21.667Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 30 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.675Z", + "modified": "2016-03-22T15:35:21.676Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 31 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.693Z", + "modified": "2016-03-22T15:35:21.695Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 32 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.704Z", + "modified": "2016-03-22T15:35:21.705Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 33 + }, + { + "fields": { + "assertion_url": "http://example.com/example.json", + "created": "2016-03-22T15:35:21.715Z", + "modified": "2016-03-22T15:35:21.716Z", + "image_url": "http://example.com/image.png", + "user": 99, + "badge_class": 1, + "data": "{}", + "backend": "" + }, + "model": "badges.badgeassertion", + "pk": 34 + }, + { + "fields": { + "default": true, + "mode": "honor", + "icon": "course_complete_badges/honor.png" + }, + "model": "badges.coursecompleteimageconfiguration", + "pk": 1 } ] diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py index 08b0c59da3..cced329c07 100644 --- a/lms/djangoapps/badges/admin.py +++ b/lms/djangoapps/badges/admin.py @@ -7,4 +7,5 @@ from config_models.admin import ConfigurationModelAdmin admin.site.register(CourseCompleteImageConfiguration) admin.site.register(BadgeClass) +# Use the standard Configuration Model Admin handler for this model. admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin) diff --git a/lms/djangoapps/badges/api/tests.py b/lms/djangoapps/badges/api/tests.py index 7b45902502..6d5bcfd842 100644 --- a/lms/djangoapps/badges/api/tests.py +++ b/lms/djangoapps/badges/api/tests.py @@ -1,6 +1,7 @@ """ Tests for the badges API views. """ +from ddt import ddt, data, unpack from django.conf import settings from django.test.utils import override_settings @@ -20,8 +21,6 @@ class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase): """ Mixin for badge API tests. """ - WILDCARD = False - CHECK_COURSE = False def setUp(self, *args, **kwargs): super(UserAssertionTestCase, self).setUp(*args, **kwargs) @@ -55,24 +54,24 @@ class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase): self.assertEqual(assertion.assertion_url, json_assertion['assertion_url']) self.check_class_structure(assertion.badge_class, json_assertion['badge_class']) - def get_course_id(self, 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 self.WILDCARD: + if wildcard: return '*' else: return unicode(badge_class.course_id) - def create_badge_class(self, **kwargs): + 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 self.CHECK_COURSE: + if check_course: return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs) return RandomBadgeClassFactory.create(**kwargs) - def get_qs_args(self, badge_class): + def get_qs_args(self, check_course, wildcard, badge_class): """ Get a dictionary to be serialized into querystring params based on class settings. """ @@ -80,8 +79,8 @@ class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase): 'issuing_component': badge_class.issuing_component, 'slug': badge_class.slug, } - if self.CHECK_COURSE: - qs_args['course_id'] = self.get_course_id(badge_class) + if check_course: + qs_args['course_id'] = self.get_course_id(wildcard, badge_class) return qs_args @@ -100,13 +99,13 @@ class TestUserBadgeAssertions(UserAssertionTestCase): 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() + self.create_badge_class(False) response = self.get_json(self.url()) # pylint: disable=no-member self.assertEqual(len(response['results']), 4) def test_assertion_structure(self): - badge_class = self.create_badge_class() + badge_class = self.create_badge_class(False) assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class) response = self.get_json(self.url()) # pylint: disable=no-member @@ -117,7 +116,6 @@ class TestUserCourseBadgeAssertions(UserAssertionTestCase): """ Test the Badge Assertions view with the course_id filter. """ - CHECK_COURSE = True def test_get_assertions(self): """ @@ -127,10 +125,10 @@ class TestUserCourseBadgeAssertions(UserAssertionTestCase): 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. + # 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 + # 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': course_key}) @@ -143,7 +141,7 @@ class TestUserCourseBadgeAssertions(UserAssertionTestCase): def test_assertion_structure(self): """ - Verify the badge assertion structure is not mangled in this mode. + 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) @@ -153,16 +151,19 @@ class TestUserCourseBadgeAssertions(UserAssertionTestCase): self.check_assertion_structure(assertion, response['results'][0]) +@ddt class TestUserBadgeAssertionsByClass(UserAssertionTestCase): """ Test the Badge Assertions view with the badge class filter. """ - def test_get_assertions(self): + @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() + 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: @@ -172,62 +173,52 @@ class TestUserBadgeAssertionsByClass(UserAssertionTestCase): course_id=CourseFactory.create().location.course_key ) BadgeAssertionFactory.create(user=self.user, badge_class=alt_class) - # Should not be in list. + # Same badge class, but different user. Should not show up in the list. for dummy in range(5): BadgeAssertionFactory.create(badge_class=badge_class) - # Also should not be in list. + # 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(badge_class), + data=self.get_qs_args(check_course, wildcard, badge_class), ) - if self.WILDCARD: + if wildcard: expected_length = 4 else: expected_length = 3 # pylint: disable=no-member self.assertEqual(len(response['results']), expected_length) - unused_class = self.create_badge_class(slug='unused_slug', issuing_component='unused_component') + 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(unused_class), + data=self.get_qs_args(check_course, wildcard, unused_class), ) # pylint: disable=no-member self.assertEqual(len(response['results']), 0) - def check_badge_class_assertion(self, badge_class): + 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(badge_class), + data=self.get_qs_args(check_course, wildcard, badge_class), ) # pylint: disable=no-member self.check_assertion_structure(assertion, response['results'][0]) - def test_assertion_structure(self): - self.check_badge_class_assertion(self.create_badge_class()) + @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)) - def test_empty_issuing_component(self): - self.check_badge_class_assertion(self.create_badge_class(issuing_component='')) - - -# pylint: disable=test-inherits-tests -class TestUserBadgeAssertionsByClassCourse(TestUserBadgeAssertionsByClass): - """ - Test searching all assertions for a user with a course bound badge class. - """ - CHECK_COURSE = True - - -# pylint: disable=test-inherits-tests -class TestUserBadgeAssertionsByClassWildCard(TestUserBadgeAssertionsByClassCourse): - """ - Test searching slugs/issuing_components across all course IDs. - """ - WILDCARD = True + @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='') + ) diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py index 4e99b85f69..060d11d05f 100644 --- a/lms/djangoapps/badges/api/views.py +++ b/lms/djangoapps/badges/api/views.py @@ -11,17 +11,18 @@ from openedx.core.lib.api.authentication import ( OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser ) -from badges.models import BadgeAssertion -from .serializers import BadgeAssertionSerializer from xmodule_django.models import CourseKeyField +from badges.models import BadgeAssertion +from .serializers import BadgeAssertionSerializer -class CourseKeyError(APIException): + +class InvalidCourseKeyError(APIException): """ Raised the course key given isn't valid. """ status_code = 400 - default_detail = "The course key provided could not be parsed." + default_detail = "The course key provided was invalid." class UserBadgeAssertions(generics.ListAPIView): @@ -118,11 +119,13 @@ class UserBadgeAssertions(generics.ListAPIView): try: course_id = CourseKey.from_string(provided_course_id) except InvalidKeyError: - raise CourseKeyError + raise InvalidCourseKeyError 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: diff --git a/lms/djangoapps/badges/backends/badgr.py b/lms/djangoapps/badges/backends/badgr.py index 265b524c51..d40efcf1c3 100644 --- a/lms/djangoapps/badges/backends/badgr.py +++ b/lms/djangoapps/badges/backends/badgr.py @@ -175,5 +175,8 @@ class BadgrBackend(BadgeBackend): 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) diff --git a/lms/djangoapps/badges/backends/tests/dummy_backend.py b/lms/djangoapps/badges/backends/tests/dummy_backend.py new file mode 100644 index 0000000000..828cb9bcdc --- /dev/null +++ b/lms/djangoapps/badges/backends/tests/dummy_backend.py @@ -0,0 +1,13 @@ +""" +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) diff --git a/lms/djangoapps/badges/backends/tests/test_badgr_backend.py b/lms/djangoapps/badges/backends/tests/test_badgr_backend.py index 455a0faeeb..ca79bf6c2b 100644 --- a/lms/djangoapps/badges/backends/tests/test_badgr_backend.py +++ b/lms/djangoapps/badges/backends/tests/test_badgr_backend.py @@ -24,6 +24,9 @@ BADGR_SETTINGS = { 'BADGR_ISSUER_SLUG': 'test-issuer', } +# Should be the hashed result of test_slug as the slug, and test_component as the component +EXAMPLE_SLUG = '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a' + # pylint: disable=protected-access @ddt.ddt @@ -104,7 +107,7 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): kwargs['data'], { 'name': 'Test Badge', - 'slug': '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', + 'slug': EXAMPLE_SLUG, 'criteria': 'https://example.com/syllabus', 'description': "Yay! It's a test badge.", } @@ -114,14 +117,14 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): """ Make sure ensure_badge_created doesn't call create_badge if we know the badge is already there. """ - BadgrBackend.badges.append('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a') + BadgrBackend.badges.append(EXAMPLE_SLUG) self.handler._create_badge = Mock() self.handler._ensure_badge_created(self.badge_class) self.assertFalse(self.handler._create_badge.called) @ddt.unpack @ddt.data( - ('badge_class', '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a'), + ('badge_class', EXAMPLE_SLUG), ('legacy_badge_class', 'test_slug'), ('no_course_badge_class', 'test_componenttest_slug') ) @@ -140,11 +143,11 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): args, kwargs = get.call_args self.assertEqual( args[0], - 'https://example.com/v1/issuer/issuers/test-issuer/badges/' - '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a' + 'https://example.com/v1/issuer/issuers/test-issuer/badges/' + + EXAMPLE_SLUG ) self.check_headers(kwargs['headers']) - self.assertIn('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', BadgrBackend.badges) + self.assertIn(EXAMPLE_SLUG, BadgrBackend.badges) self.assertFalse(self.handler._create_badge.called) @patch('requests.get') @@ -152,12 +155,12 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): response = Mock() response.status_code = 404 get.return_value = response - self.assertNotIn('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', BadgrBackend.badges) + self.assertNotIn(EXAMPLE_SLUG, BadgrBackend.badges) self.handler._create_badge = Mock() self.handler._ensure_badge_created(self.badge_class) self.assertTrue(self.handler._create_badge.called) self.assertEqual(self.handler._create_badge.call_args, call(self.badge_class)) - self.assertIn('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', BadgrBackend.badges) + self.assertIn(EXAMPLE_SLUG, BadgrBackend.badges) @patch('requests.post') def test_badge_creation_event(self, post): @@ -175,8 +178,9 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): args, kwargs = post.call_args self.assertEqual( args[0], - 'https://example.com/v1/issuer/issuers/test-issuer/badges/' - '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a/assertions' + 'https://example.com/v1/issuer/issuers/test-issuer/badges/' + + EXAMPLE_SLUG + + '/assertions' ) self.check_headers(kwargs['headers']) assertion = BadgeAssertion.objects.get(user=self.user, badge_class__course_id=self.course.location.course_key) diff --git a/lms/djangoapps/badges/events/course_meta.py b/lms/djangoapps/badges/events/course_meta.py index 5b1dd20a87..e5bbb99c33 100644 --- a/lms/djangoapps/badges/events/course_meta.py +++ b/lms/djangoapps/badges/events/course_meta.py @@ -15,6 +15,9 @@ def award_badge(config, count, user): 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: diff --git a/lms/djangoapps/badges/events/tests/test_course_meta.py b/lms/djangoapps/badges/events/tests/test_course_meta.py index d1a79c118f..2183fd02c6 100644 --- a/lms/djangoapps/badges/events/tests/test_course_meta.py +++ b/lms/djangoapps/badges/events/tests/test_course_meta.py @@ -1,14 +1,13 @@ """ Tests the course meta badging events """ - +from ddt import ddt, unpack, data from django.test.utils import override_settings from mock import patch from django.conf import settings -from badges.backends.base import BadgeBackend -from badges.tests.factories import RandomBadgeClassFactory, CourseEventBadgesConfigurationFactory, BadgeAssertionFactory +from badges.tests.factories import RandomBadgeClassFactory, CourseEventBadgesConfigurationFactory from certificates.models import GeneratedCertificate, CertificateStatuses from student.models import CourseEnrollment from student.tests.factories import UserFactory @@ -16,16 +15,9 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -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) - - +@ddt @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) -@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +@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. @@ -58,42 +50,25 @@ class CourseEnrollmentBadgeTest(ModuleStoreTestCase): CourseEnrollment.enroll(user, course_key=course.location.course_key) self.assertFalse(user.badgeassertion_set.all()) - def test_checkpoint_matches(self): + @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(3)] + courses = [CourseFactory() for _i in range(required_badges)] for course in courses: CourseEnrollment.enroll(user, course_key=course.location.course_key) # pylint: disable=no-member - assertions = user.badgeassertion_set.all() - self.assertEqual(user.badgeassertion_set.all().count(), 1) - self.assertEqual(assertions[0].badge_class, self.badge_classes[0]) - - courses = [CourseFactory() for _i in range(2)] - for course in courses: - # pylint: disable=no-member - CourseEnrollment.enroll(user, course_key=course.location.course_key) - # pylint: disable=no-member assertions = user.badgeassertion_set.all().order_by('id') - # pylint: disable=no-member - self.assertEqual(user.badgeassertion_set.all().count(), 2) - self.assertEqual(assertions[1].badge_class, self.badge_classes[1]) - - courses = [CourseFactory() for _i in range(3)] - for course in courses: - # pylint: disable=no-member - CourseEnrollment.enroll(user, course_key=course.location.course_key) - # pylint: disable=no-member - assertions = user.badgeassertion_set.all().order_by('id') - # pylint: disable=no-member - self.assertEqual(user.badgeassertion_set.all().count(), 3) - self.assertEqual(assertions[2].badge_class, self.badge_classes[2]) + self.assertEqual(user.badgeassertion_set.all().count(), checkpoint) + self.assertEqual(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.events.tests.test_course_meta.DummyBackend') +@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. @@ -130,47 +105,28 @@ class CourseCompletionBadgeTest(ModuleStoreTestCase): # pylint: disable=no-member self.assertFalse(user.badgeassertion_set.all()) - def test_checkpoint_matches(self): + @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(2)] + courses = [CourseFactory() for _i in range(required_badges)] for course in courses: GeneratedCertificate( # pylint: disable=no-member user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable ).save() # pylint: disable=no-member - assertions = user.badgeassertion_set.all() - # pylint: disable=no-member - self.assertEqual(user.badgeassertion_set.all().count(), 1) - self.assertEqual(assertions[0].badge_class, self.badge_classes[0]) - - courses = [CourseFactory() for _i in range(6)] - for course in courses: - GeneratedCertificate( - user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable - ).save() - # pylint: disable=no-member - assertions = user.badgeassertion_set.all().order_by('id') - self.assertEqual(user.badgeassertion_set.all().count(), 2) - self.assertEqual(assertions[1].badge_class, self.badge_classes[1]) - - courses = [CourseFactory() for _i in range(9)] - for course in courses: - GeneratedCertificate( - user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable - ).save() - # pylint: disable=no-member assertions = user.badgeassertion_set.all().order_by('id') # pylint: disable=no-member - self.assertEqual(user.badgeassertion_set.all().count(), 3) - self.assertEqual(assertions[2].badge_class, self.badge_classes[2]) + self.assertEqual(user.badgeassertion_set.all().count(), checkpoint) + self.assertEqual(assertions[checkpoint - 1].badge_class, self.badge_classes[checkpoint - 1]) @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) -@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +@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. diff --git a/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py b/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py index ae30c367e8..1bf50ea559 100644 --- a/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py +++ b/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py @@ -39,10 +39,9 @@ def forwards(apps, schema_editor): mode=image_config.mode, course_id=badge.course_id, ) - file_content = ContentFile(icon.read()) badge_class._meta.get_field('image').generate_filename = \ lambda inst, fn: os.path.join('badge_classes', fn) - badge_class.image.save(icon.name, file_content) + badge_class.image.name = icon.name badge_class.save() classes[(badge.course_id, badge.mode)] = badge_class if isinstance(badge.data, basestring): @@ -66,17 +65,15 @@ def forwards(apps, schema_editor): assertion.save() for configuration in BadgeImageConfiguration.objects.all(): - file_content = ContentFile(configuration.icon.read()) new_conf = CourseCompleteImageConfiguration( default=configuration.default, mode=configuration.mode, ) - new_conf.icon.save(configuration.icon.name, file_content) + new_conf.icon.name = configuration.icon.name new_conf.save() # def backwards(apps, schema_editor): - from django.core.files.base import ContentFile OldBadgeAssertion = apps.get_model("certificates", "BadgeAssertion") BadgeAssertion = apps.get_model("badges", "BadgeAssertion") BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration") @@ -97,12 +94,11 @@ def backwards(apps, schema_editor): ).save() for configuration in CourseCompleteImageConfiguration.objects.all(): - file_content = ContentFile(configuration.icon.read()) new_conf = BadgeImageConfiguration( default=configuration.default, mode=configuration.mode, ) - new_conf.icon.save(configuration.icon.name, file_content) + new_conf.icon.name = configuration.icon.name new_conf.save() diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 3258e73a29..5f4b5a4d04 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -8,20 +8,21 @@ from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ +from jsonfield import JSONField from lazy import lazy from model_utils.models import TimeStampedModel from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from badges.utils import deserialize_count_specs from config_models.models import ConfigurationModel from xmodule.modulestore.django import modulestore from xmodule_django.models import CourseKeyField -from jsonfield import JSONField def validate_badge_image(image): """ - Validates that a particular image is small enough, of the right type, and square to be a badge. + Validates that a particular image is small enough to be a badge and square. """ if image.width != image.height: raise ValidationError(_(u"The badge image must be square.")) @@ -70,6 +71,11 @@ class BadgeClass(models.Model): """ 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() @@ -253,32 +259,19 @@ class CourseEventBadgesConfiguration(ConfigurationModel): def __unicode__(self): return u"".format(u"Enabled" if self.enabled else u"Disabled") - @staticmethod - def get_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} - @property def completed_settings(self): """ Parses the settings from the courses_completed field. """ - return self.get_specs(self.courses_completed) + return deserialize_count_specs(self.courses_completed) @property def enrolled_settings(self): """ Parses the settings from the courses_completed field. """ - return self.get_specs(self.courses_enrolled) + return deserialize_count_specs(self.courses_enrolled) @property def course_group_settings(self): diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py index 4c069d3a72..83c831cb84 100644 --- a/lms/djangoapps/badges/utils.py +++ b/lms/djangoapps/badges/utils.py @@ -20,7 +20,27 @@ def requires_badges_enabled(function): """ Wrapped function which bails out early if bagdes aren't enabled. """ - if not settings.FEATURES.get('ENABLE_OPENBADGES', False): + 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} diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 96349f5e34..bd3a656548 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -15,6 +15,7 @@ from django.utils.translation import ugettext as _ from django.utils.encoding import smart_str from badges.events.course_complete import get_completion_badge +from badges.utils import badges_enabled from courseware.access import has_access from edxmako.shortcuts import render_to_response from edxmako.template import Template @@ -445,7 +446,7 @@ def _update_badge_context(context, course, user): Updates context with badge info. """ badge = None - if settings.FEATURES.get('ENABLE_OPENBADGES') and course.issue_badges: + 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] diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py index cee5aceaf6..68a8604599 100644 --- a/lms/djangoapps/lms_xblock/runtime.py +++ b/lms/djangoapps/lms_xblock/runtime.py @@ -7,6 +7,7 @@ from django.core.urlresolvers import reverse from django.conf import settings from badges.service import BadgingService +from badges.utils import badges_enabled from openedx.core.djangoapps.user_api.course_tag import api as user_course_tag_api from request_cache.middleware import RequestCache import xblock.reference.plugins @@ -215,7 +216,7 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method store = modulestore() services['settings'] = SettingsService() services['user_tags'] = UserTagsService(self) - if settings.FEATURES["ENABLE_OPENBADGES"]: + if badges_enabled(): services['badging'] = BadgingService(course_id=kwargs.get('course_id'), modulestore=store) self.request_token = kwargs.pop('request_token', None) super(LmsModuleSystem, self).__init__(**kwargs) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py index 751a683a4d..5cd0ad95f4 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -9,6 +9,7 @@ from django.views.decorators.http import require_http_methods from django_countries import countries from django.contrib.staticfiles.storage import staticfiles_storage +from badges.utils import badges_enabled from edxmako.shortcuts import render_to_response, marketing_link from microsite_configuration import microsite from openedx.core.djangoapps.user_api.accounts.api import get_account_settings @@ -96,7 +97,7 @@ def learner_profile_context(request, profile_username, user_is_staff): 'disable_courseware_js': True, } - if settings.FEATURES.get("ENABLE_OPENBADGES"): + if badges_enabled(): context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username}) return context diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index e72cdf034d..c0d801a556 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -161,6 +161,9 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True # Enable dashboard search for tests FEATURES['ENABLE_DASHBOARD_SEARCH'] = True +# Enable support for OpenBadges accomplishments +FEATURES['ENABLE_OPENBADGES'] = True + # Use MockSearchEngine as the search engine for test scenario SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" # Path at which to store the mock index @@ -184,6 +187,8 @@ PROFILE_IMAGE_BACKEND = { FEATURES['ENABLE_CSMH_EXTENDED'] = True INSTALLED_APPS += ('coursewarehistoryextended',) +BADGING_BACKEND = 'lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend' + ##################################################################### # Lastly, see if the developer has any local overrides. try: diff --git a/lms/static/js/student_profile/views/share_modal_view.js b/lms/static/js/student_profile/views/share_modal_view.js index 21744ed793..81220fe3a5 100644 --- a/lms/static/js/student_profile/views/share_modal_view.js +++ b/lms/static/js/student_profile/views/share_modal_view.js @@ -8,6 +8,7 @@ attributes: { 'class': 'badges-overlay' }, + template: _.template(badgeModalTemplate), events: { 'click .badges-modal': function (event) {event.stopPropagation();}, 'click .badges-modal .close': 'close', @@ -38,7 +39,7 @@ this.$el.find('.badges-modal').focus(); }, render: function () { - this.$el.html(_.template(badgeModalTemplate, this.model.toJSON())); + this.$el.html(this.template(this.model.toJSON())); return this; } }); diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss index 732d9a870e..a9846cff78 100644 --- a/lms/static/sass/views/_learner-profile.scss +++ b/lms/static/sass/views/_learner-profile.scss @@ -317,6 +317,14 @@ .badge-set-display { @extend .container; padding: 0 0; + + .badge-list { + // We're using a div instead of ul for accessibility, so we have to match the style + // used by ul. + margin: 1em 0; + padding: 0 0 0 40px; + } + .badge-display { width: 50%; display: inline-block; diff --git a/lms/templates/student_profile/badge_list.underscore b/lms/templates/student_profile/badge_list.underscore index 55f161b55a..23e722f9bc 100644 --- a/lms/templates/student_profile/badge_list.underscore +++ b/lms/templates/student_profile/badge_list.underscore @@ -1,4 +1,4 @@
- +
diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index b9952a817d..fdee8b046b 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from django.conf import settings from django.core.urlresolvers import reverse +from lms.djangoapps.badges.utils import badges_enabled from . import ( NAME_MIN_LENGTH, ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY, @@ -63,7 +64,7 @@ class UserReadOnlySerializer(serializers.Serializer): :return: Dict serialized account """ profile = user.profile - accomplishments_shared = settings.FEATURES.get('ENABLE_OPENBADGES') or False + accomplishments_shared = badges_enabled() data = { "username": user.username, diff --git a/test_root/uploads/course_complete_badges/honor.png b/test_root/uploads/course_complete_badges/honor.png new file mode 100644 index 0000000000000000000000000000000000000000..7dbe80ed655af3b59873c219c7de800523e9b7f6 GIT binary patch literal 13316 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