From 112a143589cec264b3d04a2d7e7d5a929a5e9813 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 3 Nov 2015 22:05:13 +0000 Subject: [PATCH 01/14] Refactor badging, move into its own app. --- lms/djangoapps/badges/__init__.py | 0 lms/djangoapps/badges/admin.py | 7 + lms/djangoapps/badges/backends/__init__.py | 0 lms/djangoapps/badges/backends/badgr.py | 179 +++++++++++++ lms/djangoapps/badges/backends/base.py | 17 ++ .../badges/backends/tests/__init__.py | 0 .../backends/tests/test_badgr_backend.py} | 137 +++++----- lms/djangoapps/badges/events/__init__.py | 0 .../badges/events/course_complete.py | 63 +++++ .../badges/events/tests/__init__.py | 0 .../events/tests/test_course_complete.py | 70 +++++ .../badges/migrations/0001_initial.py | 65 +++++ .../0002_data__migrate_assertions.py | 109 ++++++++ lms/djangoapps/badges/migrations/__init__.py | 0 lms/djangoapps/badges/models.py | 201 ++++++++++++++ lms/djangoapps/badges/tests/__init__.py | 0 lms/djangoapps/badges/tests/factories.py | 71 +++++ lms/djangoapps/badges/tests/test_models.py | 246 ++++++++++++++++++ lms/djangoapps/badges/utils.py | 12 + lms/djangoapps/certificates/admin.py | 2 - lms/djangoapps/certificates/badge_handler.py | 195 -------------- .../management/commands/regenerate_user.py | 16 +- .../certificates/migrations/0001_initial.py | 3 +- .../migrations/0003_data__default_modes.py | 9 +- .../migrations/0008_schema__remove_badges.py | 29 +++ lms/djangoapps/certificates/models.py | 131 +++------- .../certificates/tests/factories.py | 37 +-- .../tests/test_cert_management.py | 14 +- .../certificates/tests/test_models.py | 51 ---- .../certificates/tests/test_views.py | 67 +---- .../certificates/tests/test_webview_views.py | 26 +- lms/djangoapps/certificates/tests/tests.py | 9 +- lms/djangoapps/certificates/urls.py | 12 - lms/djangoapps/certificates/views/__init__.py | 1 - lms/djangoapps/certificates/views/badges.py | 31 --- lms/djangoapps/certificates/views/webview.py | 58 +++-- .../lms_xblock/test/test_runtime.py | 1 - lms/envs/aws.py | 1 + lms/envs/common.py | 10 +- 39 files changed, 1279 insertions(+), 601 deletions(-) create mode 100644 lms/djangoapps/badges/__init__.py create mode 100644 lms/djangoapps/badges/admin.py create mode 100644 lms/djangoapps/badges/backends/__init__.py create mode 100644 lms/djangoapps/badges/backends/badgr.py create mode 100644 lms/djangoapps/badges/backends/base.py create mode 100644 lms/djangoapps/badges/backends/tests/__init__.py rename lms/djangoapps/{certificates/tests/test_badge_handler.py => badges/backends/tests/test_badgr_backend.py} (57%) create mode 100644 lms/djangoapps/badges/events/__init__.py create mode 100644 lms/djangoapps/badges/events/course_complete.py create mode 100644 lms/djangoapps/badges/events/tests/__init__.py create mode 100644 lms/djangoapps/badges/events/tests/test_course_complete.py create mode 100644 lms/djangoapps/badges/migrations/0001_initial.py create mode 100644 lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py create mode 100644 lms/djangoapps/badges/migrations/__init__.py create mode 100644 lms/djangoapps/badges/models.py create mode 100644 lms/djangoapps/badges/tests/__init__.py create mode 100644 lms/djangoapps/badges/tests/factories.py create mode 100644 lms/djangoapps/badges/tests/test_models.py create mode 100644 lms/djangoapps/badges/utils.py delete mode 100644 lms/djangoapps/certificates/badge_handler.py create mode 100644 lms/djangoapps/certificates/migrations/0008_schema__remove_badges.py delete mode 100644 lms/djangoapps/certificates/views/badges.py diff --git a/lms/djangoapps/badges/__init__.py b/lms/djangoapps/badges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py new file mode 100644 index 0000000000..a30eaf4f9c --- /dev/null +++ b/lms/djangoapps/badges/admin.py @@ -0,0 +1,7 @@ +""" +Admin registration for Badge Models +""" +from django.contrib import admin +from badges.models import CourseCompleteImageConfiguration + +admin.site.register(CourseCompleteImageConfiguration) diff --git a/lms/djangoapps/badges/backends/__init__.py b/lms/djangoapps/badges/backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/backends/badgr.py b/lms/djangoapps/badges/backends/badgr.py new file mode 100644 index 0000000000..265b524c51 --- /dev/null +++ b/lms/djangoapps/badges/backends/badgr.py @@ -0,0 +1,179 @@ +""" +Badge Awarding backend for Badgr-Server. +""" +import hashlib +import logging +import mimetypes + +import requests +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from lazy import lazy +from requests.packages.urllib3.exceptions import HTTPError + +from badges.backends.base import BadgeBackend +from eventtracking import tracker + +from 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(BadgrBackend, self).__init__() + if not settings.BADGR_API_TOKEN: + raise ImproperlyConfigured("BADGR_API_TOKEN not set.") + + @lazy + def _base_url(self): + """ + Base URL for all API requests. + """ + return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG) + + @lazy + def _badge_create_url(self): + """ + URL for generating a new Badge specification + """ + return "{}/badges".format(self._base_url) + + def _badge_url(self, slug): + """ + Get the URL for a course's badge in a given mode. + """ + return "{}/{}".format(self._badge_create_url, slug) + + def _assertion_url(self, slug): + """ + URL for generating a new assertion. + """ + return "{}/assertions".format(self._badge_url(slug)) + + 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 + unicode(badge_class.course_id)).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( + u"Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n" + u"and data values %r\n" + u"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( + u"Could not determine content-type of image! Make sure it is a properly named .png file. " + u"Filename was: {}".format(image.name) + ) + files = {'image': (image.name, image, content_type)} + data = { + 'name': badge_class.display_name, + 'criteria': badge_class.criteria, + 'slug': self._slugify(badge_class), + '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) + + 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_name': assertion.badge_class.display_name, + 'issuing_component': assertion.badge_class.issuing_component, + 'course_id': unicode(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 = { + 'email': user.email, + 'evidence': evidence_url, + } + response = requests.post( + self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data, + timeout=settings.BADGR_TIMEOUT + ) + self._log_if_raised(response, data) + assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class) + assertion.data = response.json() + assertion.backend = 'BadgrBackend' + assertion.image_url = assertion.data['image'] + assertion.assertion_url = assertion.data['json']['id'] + assertion.save() + self._send_assertion_created_event(user, assertion) + return assertion + + @staticmethod + def _get_headers(): + """ + Headers to send along with the request-- used for authentication. + """ + return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)} + + def _ensure_badge_created(self, badge_class): + """ + Verify a badge has been created for this badge class, and create it if not. + """ + slug = self._slugify(badge_class) + 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): + self._ensure_badge_created(badge_class) + return self._create_assertion(badge_class, user, evidence_url) diff --git a/lms/djangoapps/badges/backends/base.py b/lms/djangoapps/badges/backends/base.py new file mode 100644 index 0000000000..7abcce2f74 --- /dev/null +++ b/lms/djangoapps/badges/backends/base.py @@ -0,0 +1,17 @@ +""" +Base class for badge backends. +""" +from abc import ABCMeta, abstractmethod + + +class BadgeBackend(object): + """ + Defines the interface for badging backends. + """ + __metaclass__ = ABCMeta + + @abstractmethod + def award(self, badge_class, user, evidence_url=None): + """ + Create a badge assertion for the user using this backend. + """ diff --git a/lms/djangoapps/badges/backends/tests/__init__.py b/lms/djangoapps/badges/backends/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/certificates/tests/test_badge_handler.py b/lms/djangoapps/badges/backends/tests/test_badgr_backend.py similarity index 57% rename from lms/djangoapps/certificates/tests/test_badge_handler.py rename to lms/djangoapps/badges/backends/tests/test_badgr_backend.py index e894102c09..455a0faeeb 100644 --- a/lms/djangoapps/certificates/tests/test_badge_handler.py +++ b/lms/djangoapps/badges/backends/tests/test_badgr_backend.py @@ -1,19 +1,22 @@ """ -Tests for the BadgeHandler, which communicates with the Badgr Server. +Tests for BadgrBackend """ from datetime import datetime -from django.test.utils import override_settings + +import ddt from django.db.models.fields.files import ImageFieldFile +from django.test.utils import override_settings from lazy.lazy import lazy from mock import patch, Mock, call -from certificates.models import BadgeAssertion, BadgeImageConfiguration + +from badges.backends.badgr import BadgrBackend +from badges.models import BadgeAssertion +from badges.tests.factories import BadgeClassFactory from openedx.core.lib.tests.assertions.events import assert_event_matches -from track.tests import EventTrackingTestCase -from xmodule.modulestore.tests.factories import CourseFactory -from certificates.badge_handler import BadgeHandler -from certificates.tests.factories import BadgeImageConfigurationFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory +from track.tests import EventTrackingTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory BADGR_SETTINGS = { 'BADGR_API_TOKEN': '12345', @@ -22,8 +25,10 @@ BADGR_SETTINGS = { } +# pylint: disable=protected-access +@ddt.ddt @override_settings(**BADGR_SETTINGS) -class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): +class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): """ Tests the BadgeHandler object """ @@ -31,7 +36,7 @@ class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): """ Create a course and user to test with. """ - super(BadgeHandlerTestCase, self).setUp() + super(BadgrBackendTestCase, self).setUp() # Need key to be deterministic to test slugs. self.course = CourseFactory.create( org='edX', course='course_test', run='test_run', display_name='Badged', @@ -40,9 +45,13 @@ class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): ) self.user = UserFactory.create(email='example@example.com') CourseEnrollmentFactory.create(user=self.user, course_id=self.course.location.course_key, mode='honor') - # Need for force empty this dict on each run. - BadgeHandler.badges = {} - BadgeImageConfigurationFactory() + # 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() @lazy def handler(self): @@ -50,21 +59,21 @@ class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings overrides aren't in place. """ - return BadgeHandler(self.course.location.course_key) + return BadgrBackend() def test_urls(self): """ Make sure the handler generates the correct URLs for different API tasks. """ - self.assertEqual(self.handler.base_url, 'https://example.com/v1/issuer/issuers/test-issuer') - self.assertEqual(self.handler.badge_create_url, 'https://example.com/v1/issuer/issuers/test-issuer/badges') + self.assertEqual(self.handler._base_url, 'https://example.com/v1/issuer/issuers/test-issuer') + self.assertEqual(self.handler._badge_create_url, 'https://example.com/v1/issuer/issuers/test-issuer/badges') self.assertEqual( - self.handler.badge_url('honor'), - 'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b' + self.handler._badge_url('test_slug_here'), + 'https://example.com/v1/issuer/issuers/test-issuer/badges/test_slug_here' ) self.assertEqual( - self.handler.assertion_url('honor'), - 'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b/assertions' + self.handler._assertion_url('another_test_slug'), + 'https://example.com/v1/issuer/issuers/test-issuer/badges/another_test_slug/assertions' ) def check_headers(self, headers): @@ -73,121 +82,110 @@ class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): """ self.assertEqual(headers, {'Authorization': 'Token 12345'}) - 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. - """ - self.assertEqual( - self.handler.course_slug('honor'), - 'edxcourse_testtest_run_honor_fc5519b' - ) - self.assertEqual( - self.handler.course_slug('verified'), - 'edxcourse_testtest_run_verified_a199ec0' - ) - def test_get_headers(self): """ Check to make sure the handler generates appropriate HTTP headers. """ - self.check_headers(self.handler.get_headers()) + self.check_headers(self.handler._get_headers()) @patch('requests.post') def test_create_badge(self, post): """ Verify badge spec creation works. """ - self.handler.create_badge('honor') + self.handler._create_badge(self.badge_class) args, kwargs = post.call_args self.assertEqual(args[0], 'https://example.com/v1/issuer/issuers/test-issuer/badges') - self.assertEqual(kwargs['files']['image'][0], BadgeImageConfiguration.objects.get(mode='honor').icon.name) + self.assertEqual(kwargs['files']['image'][0], self.badge_class.image.name) self.assertIsInstance(kwargs['files']['image'][1], ImageFieldFile) self.assertEqual(kwargs['files']['image'][2], 'image/png') self.check_headers(kwargs['headers']) self.assertEqual( kwargs['data'], { - 'name': 'Badged', - 'slug': 'edxcourse_testtest_run_honor_fc5519b', - 'criteria': 'https://edx.org/courses/edX/course_test/test_run/about', - 'description': 'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)', + 'name': 'Test Badge', + 'slug': '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', + 'criteria': 'https://example.com/syllabus', + 'description': "Yay! It's a test badge.", } ) - 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 - self.assertEqual(BadgeHandler.badge_description(self.course, 'honor'), 'Completed the course "Badged" (honor)') - 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. """ - BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'] = True - self.handler.create_badge = Mock() - self.handler.ensure_badge_created('honor') - self.assertFalse(self.handler.create_badge.called) + BadgrBackend.badges.append('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a') + 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'), + ('legacy_badge_class', 'test_slug'), + ('no_course_badge_class', 'test_componenttest_slug') + ) + def test_slugs(self, badge_class_type, slug): + self.assertEqual(self.handler._slugify(getattr(self, badge_class_type)), slug) @patch('requests.get') def test_ensure_badge_created_checks(self, get): response = Mock() response.status_code = 200 get.return_value = response - self.assertNotIn('edxcourse_testtest_run_honor_fc5519b', BadgeHandler.badges) - self.handler.create_badge = Mock() - self.handler.ensure_badge_created('honor') + self.assertNotIn('test_componenttest_slug', BadgrBackend.badges) + self.handler._create_badge = Mock() + self.handler._ensure_badge_created(self.badge_class) self.assertTrue(get.called) args, kwargs = get.call_args self.assertEqual( args[0], 'https://example.com/v1/issuer/issuers/test-issuer/badges/' - 'edxcourse_testtest_run_honor_fc5519b' + '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a' ) self.check_headers(kwargs['headers']) - self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b']) - self.assertFalse(self.handler.create_badge.called) + self.assertIn('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', BadgrBackend.badges) + self.assertFalse(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 - self.assertNotIn('edxcourse_testtest_run_honor_fc5519b', BadgeHandler.badges) - self.handler.create_badge = Mock() - self.handler.ensure_badge_created('honor') - self.assertTrue(self.handler.create_badge.called) - self.assertEqual(self.handler.create_badge.call_args, call('honor')) - self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b']) + self.assertNotIn('15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a', 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) @patch('requests.post') def test_badge_creation_event(self, post): result = { 'json': {'id': 'http://www.example.com/example'}, 'image': 'http://www.example.com/example.png', - 'slug': 'test_assertion_slug', + 'badge': 'test_assertion_slug', 'issuer': 'https://example.com/v1/issuer/issuers/test-issuer', } response = Mock() response.json.return_value = result post.return_value = response self.recreate_tracker() - self.handler.create_assertion(self.user, 'honor') + self.handler._create_assertion(self.badge_class, self.user, 'https://example.com/irrefutable_proof') args, kwargs = post.call_args self.assertEqual( args[0], 'https://example.com/v1/issuer/issuers/test-issuer/badges/' - 'edxcourse_testtest_run_honor_fc5519b/assertions' + '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a/assertions' ) self.check_headers(kwargs['headers']) - assertion = BadgeAssertion.objects.get(user=self.user, course_id=self.course.location.course_key) + assertion = BadgeAssertion.objects.get(user=self.user, badge_class__course_id=self.course.location.course_key) self.assertEqual(assertion.data, result) self.assertEqual(assertion.image_url, 'http://www.example.com/example.png') + self.assertEqual(assertion.assertion_url, 'http://www.example.com/example') self.assertEqual(kwargs['data'], { 'email': 'example@example.com', - 'evidence': 'https://edx.org/certificates/user/2/course/edX/course_test/test_run?evidence_visit=1' + 'evidence': 'https://example.com/irrefutable_proof' }) assert_event_matches({ 'name': 'edx.badge.assertion.created', @@ -196,6 +194,9 @@ class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): 'course_id': unicode(self.course.location.course_key), 'enrollment_mode': 'honor', 'assertion_id': assertion.id, + 'badge_name': 'Test Badge', + 'badge_slug': 'test_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/v1/issuer/issuers/test-issuer', diff --git a/lms/djangoapps/badges/events/__init__.py b/lms/djangoapps/badges/events/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/events/course_complete.py b/lms/djangoapps/badges/events/course_complete.py new file mode 100644 index 0000000000..1c9fd22fad --- /dev/null +++ b/lms/djangoapps/badges/events/course_complete.py @@ -0,0 +1,63 @@ +""" +Helper functions for the course complete event that was originally included with the Badging MVP. +""" +import hashlib + +from django.core.urlresolvers import reverse +from django.template.defaultfilters import slugify +from django.utils.translation import ugettext_lazy as _ + +from badges.utils import site_prefix + + +# NOTE: As these functions are carry-overs from the initial badging implementation, they are used in +# migrations. Please check the badge migrations when changing any of these functions. + +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. + digest = hashlib.sha256(u"{}{}".format(unicode(course_key), unicode(mode))).hexdigest()[:7] + base_slug = slugify(unicode(course_key) + u'_{}_'.format(mode))[:248] + return base_slug + digest + + +def badge_description(course, mode): + """ + Returns a description for the earned badge. + """ + if course.end: + return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format( + start_date=course.start.date(), + end_date=course.end.date(), + course_name=course.display_name, + course_mode=mode, + ) + else: + return _(u'Completed the course "{course_name}" ({course_mode})').format( + course_name=course.display_name, + course_mode=mode, + ) + + +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. + """ + return site_prefix() + reverse( + 'certificates:html_view', kwargs={'user_id': user_id, 'course_id': unicode(course_key)}) + '?evidence_visit=1' + + +def criteria(course_key): + """ + Constructs the 'criteria' URL from the course about page. + """ + about_path = reverse('about_course', kwargs={'course_id': unicode(course_key)}) + return u'{}{}'.format(site_prefix(), about_path) diff --git a/lms/djangoapps/badges/events/tests/__init__.py b/lms/djangoapps/badges/events/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/events/tests/test_course_complete.py b/lms/djangoapps/badges/events/tests/test_course_complete.py new file mode 100644 index 0000000000..0fb072ee80 --- /dev/null +++ b/lms/djangoapps/badges/events/tests/test_course_complete.py @@ -0,0 +1,70 @@ +""" +Tests for the course completion helper functions. +""" +from datetime import datetime + +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from badges.events import course_complete + + +class CourseCompleteTestCase(ModuleStoreTestCase): + """ + Tests for the course completion helper functions. + """ + def setUp(self, **kwargs): + super(CourseCompleteTestCase, self).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. + """ + self.assertEqual( + course_complete.course_slug(self.course_key, 'honor'), + 'edxcourse_testtest_run_honor_fc5519b' + ) + self.assertEqual( + course_complete.course_slug(self.course_key, 'verified'), + 'edxcourse_testtest_run_verified_a199ec0' + ) + + def test_dated_description(self): + """ + Verify that a course with start/end dates contains a description with them. + """ + self.assertEqual( + 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 + self.assertEqual( + 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() + self.assertEqual( + 'https://edx.org/certificates/user/{user_id}/course/{course_key}?evidence_visit=1'.format( + user_id=user.id, course_key=self.course_key + ), + course_complete.evidence_url(user.id, self.course_key) + ) diff --git a/lms/djangoapps/badges/migrations/0001_initial.py b/lms/djangoapps/badges/migrations/0001_initial.py new file mode 100644 index 0000000000..000d2308ec --- /dev/null +++ b/lms/djangoapps/badges/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import badges.models +from django.conf import settings +import xmodule_django.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BadgeAssertion', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('data', jsonfield.fields.JSONField()), + ('backend', models.CharField(max_length=50)), + ('image_url', models.URLField()), + ('assertion_url', models.URLField()), + ], + ), + migrations.CreateModel( + name='BadgeClass', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('slug', models.SlugField(max_length=255)), + ('issuing_component', models.SlugField(default=b'', blank=True)), + ('display_name', models.CharField(max_length=255)), + ('course_id', xmodule_django.models.CourseKeyField(default=None, max_length=255, blank=True)), + ('description', models.TextField()), + ('criteria', models.TextField()), + ('mode', models.CharField(default=b'', max_length=100, blank=True)), + ('image', models.ImageField(upload_to=b'badge_classes', validators=[badges.models.validate_badge_image])), + ], + ), + migrations.CreateModel( + name='CourseCompleteImageConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)), + ('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'course_complete_badges', validators=[badges.models.validate_badge_image])), + ('default', models.BooleanField(default=False, 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.')), + ], + ), + migrations.AlterUniqueTogether( + name='badgeclass', + unique_together=set([('slug', 'issuing_component', 'course_id')]), + ), + migrations.AddField( + model_name='badgeassertion', + name='badge_class', + field=models.ForeignKey(to='badges.BadgeClass'), + ), + migrations.AddField( + model_name='badgeassertion', + name='user', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py b/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py new file mode 100644 index 0000000000..56be82d6ed --- /dev/null +++ b/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import json + +import os +from django.db import migrations, models + + +def forwards(apps, schema_editor): + """ + Migrate the initial badge classes, assertions, and course image configurations from certificates. + """ + from django.core.files.base import ContentFile + from xmodule.modulestore.django import modulestore + from badges.events import course_complete + db_alias = schema_editor.connection.alias + # This will need to be changed if badges/certificates get moved out of the default db for some reason. + if db_alias != 'default': + return + classes = {} + OldBadgeAssertion = apps.get_model("certificates", "BadgeAssertion") + BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration") + BadgeAssertion = apps.get_model("badges", "BadgeAssertion") + BadgeClass = apps.get_model("badges", "BadgeClass") + CourseCompleteImageConfiguration = apps.get_model("badges", "CourseCompleteImageConfiguration") + for badge in OldBadgeAssertion.objects.all(): + if (badge.course_id, badge.mode) not in classes: + course = modulestore().get_course(badge.course_id) + image_config = BadgeImageConfiguration.objects.get(mode=badge.mode) + icon = image_config.icon + badge_class = BadgeClass( + display_name=course.display_name, + criteria=course_complete.evidence_url(badge.user_id, badge.course_id), + description=course_complete.badge_description(course, badge.mode), + slug=course_complete.course_slug(badge.course_id, badge.mode), + 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.save() + classes[(badge.course_id, badge.mode)] = badge_class + if isinstance(badge.data, basestring): + data = badge.data + else: + data = json.dumps(badge.data) + BadgeAssertion( + user_id=badge.user_id, + badge_class=classes[(badge.course_id, badge.mode)], + data=data, + backend='BadgrBackend', + image_url=badge.data['image'], + assertion_url=badge.data['json']['id'], + ).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.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") + CourseCompleteImageConfiguration = apps.get_model("badges", "CourseCompleteImageConfiguration") + for badge in BadgeAssertion.objects.all(): + if not badge.badge_class.mode: + # Can't preserve old badges without modes. + continue + if isinstance(badge.data, basestring): + data = badge.data + else: + data = json.dumps(badge.data) + OldBadgeAssertion( + user_id=badge.user_id, + course_id=badge.badge_class.course_id, + mode=badge.badge_class.mode, + data=data, + ).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.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('badges', '0001_initial'), + ('certificates', '0007_certificateinvalidation') + ] + + operations = [ + migrations.RunPython(forwards, backwards) + ] diff --git a/lms/djangoapps/badges/migrations/__init__.py b/lms/djangoapps/badges/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py new file mode 100644 index 0000000000..909ccaec1e --- /dev/null +++ b/lms/djangoapps/badges/models.py @@ -0,0 +1,201 @@ +""" +Database models for the badges app +""" +from importlib import import_module + +from django.conf import settings +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 lazy import lazy + +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. + """ + if image.width != image.height: + raise ValidationError(_(u"The badge image must be square.")) + if not image.size < (250 * 1024): + raise ValidationError(_(u"The badge image file size must be less than 250KB.")) + + +def validate_lowercase(string): + """ + Validates that a string is lowercase. + """ + if not string == string.lower(): + raise ValidationError(_(u"This value must be all lowercase.")) + + +class BadgeClass(models.Model): + """ + Specifies a badge class to be registered with a backend. + """ + slug = models.SlugField(max_length=255, validators=[validate_lowercase]) + 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 __unicode__(self): + return u"".format( + slug=self.slug, issuing_component=self.issuing_component + ) + + @classmethod + def get_badge_class( + cls, slug, issuing_component, display_name, description, criteria, image_file_handle, + mode='', course_id=None, create=True + ): + """ + 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. + """ + slug = slug.lower() + issuing_component = issuing_component.lower() + 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) + + def save(self, **kwargs): + """ + 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(BadgeClass, self).save(**kwargs) + + class Meta(object): + app_label = "badges" + unique_together = (('slug', 'issuing_component', 'course_id'),) + + +class BadgeAssertion(models.Model): + """ + Tracks badges on our side of the badge baking transaction + """ + user = models.ForeignKey(User) + badge_class = models.ForeignKey(BadgeClass) + data = JSONField() + backend = models.CharField(max_length=50) + image_url = models.URLField() + assertion_url = models.URLField() + + def __unicode__(self): + return u"<{username} Badge Assertion for {slug} for {issuing_component}".format( + username=self.user.username, slug=self.badge_class.slug, + issuing_component=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(object): + app_label = "badges" + + +class CourseCompleteImageConfiguration(models.Model): + """ + Contains the icon configuration for badges for a specific course mode. + """ + mode = models.CharField( + max_length=125, + help_text=_(u'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=_( + u"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=_( + u"Set this value to True if you want this image to be the default image for any course modes " + u"that do not have a specified badge image. You can have only one default image." + ), + default=False, + ) + + def __unicode__(self): + return u"".format( + mode=self.mode, + default=u" (default)" if self.default else u'' + ) + + def clean(self): + """ + Make sure there's not more than one default. + """ + # pylint: disable=no-member + if self.default and CourseCompleteImageConfiguration.objects.filter(default=True).exclude(id=self.id): + raise ValidationError(_(u"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(object): + app_label = "badges" diff --git a/lms/djangoapps/badges/tests/__init__.py b/lms/djangoapps/badges/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py new file mode 100644 index 0000000000..dfb60544df --- /dev/null +++ b/lms/djangoapps/badges/tests/factories.py @@ -0,0 +1,71 @@ +""" +Factories for Badge tests +""" +from random import random + +import factory +from django.core.files.base import ContentFile +from factory import DjangoModelFactory +from factory.django import ImageField + +from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass +from student.tests.factories import UserFactory + + +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(DjangoModelFactory): + """ + Factory for BadgeImageConfigurations + """ + class Meta(object): + model = CourseCompleteImageConfiguration + + mode = 'honor' + icon = factory.LazyAttribute(generate_dummy_image) + + +class BadgeClassFactory(DjangoModelFactory): + """ + Factory for BadgeClass + """ + class Meta(object): + model = BadgeClass + + slug = 'test_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())) + + +class BadgeAssertionFactory(DjangoModelFactory): + """ + Factory for BadgeAssertions + """ + class Meta(object): + 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' diff --git a/lms/djangoapps/badges/tests/test_models.py b/lms/djangoapps/badges/tests/test_models.py new file mode 100644 index 0000000000..0a17c92b53 --- /dev/null +++ b/lms/djangoapps/badges/tests/test_models.py @@ -0,0 +1,246 @@ +""" +Tests for the Badges app models. +""" +from django.core.exceptions import ValidationError +from django.core.files.images import ImageFile +from django.test import TestCase +from django.test.utils import override_settings +from mock import patch +from nose.plugins.attrib import attr +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from xmodule.modulestore.tests.factories import CourseFactory + +from badges.models import CourseCompleteImageConfiguration, validate_badge_image, BadgeClass, BadgeAssertion +from badges.tests.factories import BadgeClassFactory, BadgeAssertionFactory, RandomBadgeClassFactory +from certificates.tests.test_models import TEST_DATA_ROOT +from student.tests.factories import UserFactory + + +class ImageFetchingMixin(object): + """ + Provides the ability to grab a badge image from the test data root. + """ + def get_image(self, name): + """ + Get one of the test images from the test data directory. + """ + return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png')) + + +@attr('shard_1') +class BadgeImageConfigurationTest(TestCase, ImageFetchingMixin): + """ + Test the validation features of BadgeImageConfiguration. + """ + + def test_no_double_default(self): + """ + Verify that creating two configurations as default is not permitted. + """ + CourseCompleteImageConfiguration(mode='test', icon=self.get_image('good'), default=True).save() + self.assertRaises( + ValidationError, + CourseCompleteImageConfiguration(mode='test2', icon=self.get_image('good'), default=True).full_clean + ) + + def test_runs_validator(self): + """ + Verify that the image validator is triggered when cleaning the model. + """ + self.assertRaises( + ValidationError, + CourseCompleteImageConfiguration(mode='test2', icon=self.get_image('unbalanced')).full_clean + ) + + +class DummyBackend(object): + """ + Dummy badge backend, used for testing. + """ + + +class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): + """ + Test BadgeClass functionality + """ + # 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. + """ + self.assertIsInstance(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. + badge_class = BadgeClass.get_badge_class( + slug='test_slug', issuing_component='test_component', description='Attempted override', + criteria='test', display_name='Testola', image_file_handle=self.get_image('good') + ) + # These defaults are set on the factory. + self.assertEqual(badge_class.criteria, 'https://example.com/syllabus') + self.assertEqual(badge_class.display_name, 'Test Badge') + self.assertEqual(badge_class.description, "Yay! It's a test badge.") + # File name won't always be the same. + self.assertEqual(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) + badge_class = BadgeClass.get_badge_class( + slug='test_slug', issuing_component='test_component', description='Attempted override', + criteria='test', display_name='Testola', image_file_handle=self.get_image('good') + ) + 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=self.get_image('good'), + course_id=course_key, + ) + self.assertNotEqual(badge_class.id, course_badge_class.id) + self.assertEqual(course_badge_class.id, premade_badge_class.id) + + def test_get_badge_class_create(self): + """ + Verify fetching a badge creates it if it doesn't yet exist. + """ + 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=self.get_image('good') + ) + # This should have been saved before being passed back. + self.assertTrue(badge_class.id) + self.assertEqual(badge_class.slug, 'new_slug') + self.assertEqual(badge_class.issuing_component, 'new_component') + self.assertEqual(badge_class.description, 'This is a test') + self.assertEqual(badge_class.criteria, 'https://example.com/test_criteria') + self.assertEqual(badge_class.display_name, 'Super Badge') + self.assertEqual(badge_class.image.name.rsplit('/', 1)[-1], 'good.png') + + 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', description=None, + criteria=None, display_name=None, + image_file_handle=None, create=False + ) + self.assertIsNone(badge_class) + # 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 + ) + self.assertIsNone(badge_class) + + def test_get_badge_class_validate(self): + """ + Verify handing a broken image to get_badge_class raises a validation error upon creation. + """ + 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=self.get_image('unbalanced') + ) + + 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() + self.assertFalse(badge_class.get_for_user(user)) + assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=user) + self.assertEqual(list(badge_class.get_for_user(user)), [assertion]) + + @override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.badgr.BadgrBackend', BADGR_API_TOKEN='test') + @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') + self.assertTrue(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. + """ + self.assertRaises( + ValidationError, + BadgeClass( + slug='test', issuing_component='test2', criteria='test3', + description='test4', image=self.get_image('unbalanced') + ).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 assertItemsEqual 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() + self.assertEqual(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() + self.assertEqual(course_scoped_assertions, course_assertions) + + +class ValidBadgeImageTest(TestCase, ImageFetchingMixin): + """ + Tests the badge image field validator. + """ + def test_good_image(self): + """ + Verify that saving a valid badge image is no problem. + """ + validate_badge_image(self.get_image('good')) + + def test_unbalanced_image(self): + """ + Verify that setting an image with an uneven width and height raises an error. + """ + unbalanced = ImageFile(self.get_image('unbalanced')) + self.assertRaises(ValidationError, validate_badge_image, unbalanced) + + def test_large_image(self): + """ + Verify that setting an image that is too big raises an error. + """ + large = self.get_image('large') + self.assertRaises(ValidationError, validate_badge_image, large) diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py new file mode 100644 index 0000000000..9e2d98f304 --- /dev/null +++ b/lms/djangoapps/badges/utils.py @@ -0,0 +1,12 @@ +""" +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 = u"https" if settings.HTTPS == "on" else u"http" + return u'{}://{}'.format(scheme, settings.SITE_NAME) diff --git a/lms/djangoapps/certificates/admin.py b/lms/djangoapps/certificates/admin.py index 66243bc555..937ed54b20 100644 --- a/lms/djangoapps/certificates/admin.py +++ b/lms/djangoapps/certificates/admin.py @@ -8,7 +8,6 @@ from util.organizations_helpers import get_organizations from certificates.models import ( CertificateGenerationConfiguration, CertificateHtmlViewConfiguration, - BadgeImageConfiguration, CertificateTemplate, CertificateTemplateAsset, GeneratedCertificate, @@ -61,7 +60,6 @@ class GeneratedCertificateAdmin(admin.ModelAdmin): admin.site.register(CertificateGenerationConfiguration) admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin) -admin.site.register(BadgeImageConfiguration) admin.site.register(CertificateTemplate, CertificateTemplateAdmin) admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin) admin.site.register(GeneratedCertificate, GeneratedCertificateAdmin) diff --git a/lms/djangoapps/certificates/badge_handler.py b/lms/djangoapps/certificates/badge_handler.py deleted file mode 100644 index 2b12a2bea4..0000000000 --- a/lms/djangoapps/certificates/badge_handler.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -BadgeHandler object-- used to award Badges to users who have completed courses. -""" -import hashlib -import logging -import mimetypes -from eventtracking import tracker -import requests -from django.template.defaultfilters import slugify -from django.utils.translation import ugettext as _ - -from django.conf import settings -from django.core.urlresolvers import reverse -from lazy import lazy -from requests.packages.urllib3.exceptions import HTTPError -from certificates.models import BadgeAssertion, BadgeImageConfiguration -from student.models import CourseEnrollment -from xmodule.modulestore.django import modulestore - -LOGGER = logging.getLogger(__name__) - - -class BadgeHandler(object): - """ - The only properly public method of this class is 'award'. If an alternative object is created for a different - badging service, the other methods don't need to be reproduced. - """ - # Global caching dict - badges = {} - - def __init__(self, course_key): - self.course_key = course_key - assert settings.BADGR_API_TOKEN - - @lazy - def base_url(self): - """ - Base URL for all API requests. - """ - return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG) - - @lazy - def badge_create_url(self): - """ - URL for generating a new Badge specification - """ - return "{}/badges".format(self.base_url) - - def badge_url(self, mode): - """ - Get the URL for a course's badge in a given mode. - """ - return "{}/{}".format(self.badge_create_url, self.course_slug(mode)) - - def assertion_url(self, mode): - """ - URL for generating a new assertion. - """ - return "{}/assertions".format(self.badge_url(mode)) - - def course_slug(self, mode): - """ - 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. - digest = hashlib.sha256(u"{}{}".format(unicode(self.course_key), unicode(mode))).hexdigest()[:7] - base_slug = slugify(unicode(self.course_key) + u'_{}_'.format(mode))[:248] - return base_slug + digest - - def log_if_raised(self, response, data): - """ - Log server response if there was an error. - """ - try: - response.raise_for_status() - except HTTPError: - LOGGER.error( - u"Encountered an error when contacting the Badgr-Server. Request sent to %s with headers %s.\n" - u"and data values %s\n" - u"Response status was %s.\n%s", - repr(response.request.url), repr(response.request.headers), - repr(data), - response.status_code, response.body - ) - raise - - def get_headers(self): - """ - Headers to send along with the request-- used for authentication. - """ - return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)} - - def ensure_badge_created(self, mode): - """ - Verify a badge has been created for this mode of the course, and, if not, create it - """ - if self.course_slug(mode) in BadgeHandler.badges: - return - response = requests.get(self.badge_url(mode), headers=self.get_headers()) - if response.status_code != 200: - self.create_badge(mode) - BadgeHandler.badges[self.course_slug(mode)] = True - - @staticmethod - def badge_description(course, mode): - """ - Returns a description for the earned badge. - """ - if course.end: - return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format( - start_date=course.start.date(), - end_date=course.end.date(), - course_name=course.display_name, - course_mode=mode, - ) - else: - return _(u'Completed the course "{course_name}" ({course_mode})').format( - course_name=course.display_name, - course_mode=mode, - ) - - def site_prefix(self): - """ - Get the prefix for the site URL-- protocol and server name. - """ - scheme = u"https" if settings.HTTPS == "on" else u"http" - return u'{}://{}'.format(scheme, settings.SITE_NAME) - - def create_badge(self, mode): - """ - Create the badge spec for a course's mode. - """ - course = modulestore().get_course(self.course_key) - image = BadgeImageConfiguration.image_for_mode(mode) - # 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." - ) - files = {'image': (image.name, image, content_type)} - about_path = reverse('about_course', kwargs={'course_id': unicode(self.course_key)}) - data = { - 'name': course.display_name, - 'criteria': u'{}{}'.format(self.site_prefix(), about_path), - 'slug': self.course_slug(mode), - 'description': self.badge_description(course, mode) - } - result = requests.post(self.badge_create_url, headers=self.get_headers(), data=data, files=files) - self.log_if_raised(result, data) - - 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, - 'course_id': unicode(self.course_key), - 'enrollment_mode': assertion.mode, - 'assertion_id': assertion.id, - 'assertion_image_url': assertion.data['image'], - 'assertion_json_url': assertion.data['json']['id'], - 'issuer': assertion.data['issuer'], - } - ) - - def create_assertion(self, user, mode): - """ - Register an assertion with the Badgr server for a particular user in a particular course mode for - this course. - """ - data = { - 'email': user.email, - 'evidence': self.site_prefix() + reverse( - 'certificates:html_view', kwargs={'user_id': user.id, 'course_id': unicode(self.course_key)} - ) + '?evidence_visit=1' - } - response = requests.post(self.assertion_url(mode), headers=self.get_headers(), data=data) - self.log_if_raised(response, data) - assertion, __ = BadgeAssertion.objects.get_or_create(course_id=self.course_key, user=user, mode=mode) - assertion.data = response.json() - assertion.save() - self.send_assertion_created_event(user, assertion) - - def award(self, user): - """ - Award a user a badge for their work on the course. - """ - mode = CourseEnrollment.objects.get(user=user, course_id=self.course_key).mode - self.ensure_badge_created(mode) - self.create_assertion(user, mode) diff --git a/lms/djangoapps/certificates/management/commands/regenerate_user.py b/lms/djangoapps/certificates/management/commands/regenerate_user.py index 0a9aa01d29..7461a9654b 100644 --- a/lms/djangoapps/certificates/management/commands/regenerate_user.py +++ b/lms/djangoapps/certificates/management/commands/regenerate_user.py @@ -8,8 +8,9 @@ from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey + +from certificates.models import get_completion_badge from xmodule.modulestore.django import modulestore -from certificates.models import BadgeAssertion from certificates.api import regenerate_user_certificates LOGGER = logging.getLogger(__name__) @@ -110,6 +111,12 @@ class Command(BaseCommand): course_id ) + badge_class = get_completion_badge(course_id, student) + badge = badge_class.get_for_user(student) + if badge: + badge.delete() + LOGGER.info(u"Cleared badge for student %s.", student.id) + # Add the certificate request to the queue ret = regenerate_user_certificates( student, course_id, course=course, @@ -118,13 +125,6 @@ class Command(BaseCommand): insecure=options['insecure'] ) - try: - badge = BadgeAssertion.objects.get(user=student, course_id=course_id) - badge.delete() - LOGGER.info(u"Cleared badge for student %s.", student.id) - except BadgeAssertion.DoesNotExist: - pass - LOGGER.info( ( u"Added a certificate regeneration task to the XQueue " diff --git a/lms/djangoapps/certificates/migrations/0001_initial.py b/lms/djangoapps/certificates/migrations/0001_initial.py index 79801ff8b9..4584d6eb8e 100644 --- a/lms/djangoapps/certificates/migrations/0001_initial.py +++ b/lms/djangoapps/certificates/migrations/0001_initial.py @@ -9,6 +9,7 @@ import django_extensions.db.fields import django_extensions.db.fields.json import django.db.models.deletion import django.utils.timezone +from badges.models import validate_badge_image from django.conf import settings @@ -34,7 +35,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)), - ('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'badges', validators=[certificates.models.validate_badge_image])), + ('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'badges', validators=[validate_badge_image])), ('default', models.BooleanField(default=False, 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.')), ], ), diff --git a/lms/djangoapps/certificates/migrations/0003_data__default_modes.py b/lms/djangoapps/certificates/migrations/0003_data__default_modes.py index 68b761e5b5..7db444a76d 100644 --- a/lms/djangoapps/certificates/migrations/0003_data__default_modes.py +++ b/lms/djangoapps/certificates/migrations/0003_data__default_modes.py @@ -10,8 +10,11 @@ from django.core.files import File def forwards(apps, schema_editor): """Add default modes""" BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration") - - objects = BadgeImageConfiguration.objects + db_alias = schema_editor.connection.alias + # This will need to be changed if badges/certificates get moved out of the default db for some reason. + if db_alias != 'default': + return + objects = BadgeImageConfiguration.objects.using(db_alias) if not objects.exists(): for mode in ['honor', 'verified', 'professional']: conf = objects.create(mode=mode) @@ -34,5 +37,5 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(forwards,backwards) + migrations.RunPython(forwards, backwards) ] diff --git a/lms/djangoapps/certificates/migrations/0008_schema__remove_badges.py b/lms/djangoapps/certificates/migrations/0008_schema__remove_badges.py new file mode 100644 index 0000000000..5e4b5f9a8c --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0008_schema__remove_badges.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0007_certificateinvalidation'), + ('badges', '0002_data__migrate_assertions'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='badgeassertion', + unique_together=set([]), + ), + migrations.RemoveField( + model_name='badgeassertion', + name='user', + ), + migrations.DeleteModel( + name='BadgeImageConfiguration', + ), + migrations.DeleteModel( + name='BadgeAssertion', + ), + ] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 06e4979409..9de0d2a59f 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -47,23 +47,25 @@ Eligibility: """ import json import logging -import os import uuid +import os +from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import Count from django.db.models.signals import post_save from django.dispatch import receiver -from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django_extensions.db.fields import CreationDateTimeField -from django_extensions.db.fields.json import JSONField from model_utils import Choices from model_utils.models import TimeStampedModel from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED + +from badges.events import course_complete +from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass from config_models.models import ConfigurationModel from instructor_task.models import InstructorTask from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled @@ -96,13 +98,15 @@ class CertificateStatuses(object): error: "error states" } + PASSED_STATUSES = (downloadable, generating, regenerating) + @classmethod def is_passing_status(cls, status): """ Given the status of a certificate, return a boolean indicating whether the student passed the course. """ - return status in [cls.downloadable, cls.generating] + return status in cls.PASSED_STATUSES class CertificateSocialNetworks(object): @@ -893,93 +897,6 @@ class CertificateHtmlViewConfiguration(ConfigurationModel): return json_data -class BadgeAssertion(models.Model): - """ - Tracks badges on our side of the badge baking transaction - """ - user = models.ForeignKey(User) - course_id = CourseKeyField(max_length=255, blank=True, default=None) - # Mode a badge was awarded for. - mode = models.CharField(max_length=100) - data = JSONField() - - @property - def image_url(self): - """ - Get the image for this assertion. - """ - - return self.data['image'] - - @property - def assertion_url(self): - """ - Get the public URL for the assertion. - """ - return self.data['json']['id'] - - class Meta(object): - unique_together = (('course_id', 'user', 'mode'),) - app_label = "certificates" - - -def validate_badge_image(image): - """ - Validates that a particular image is small enough, of the right type, and square to be a badge. - """ - if image.width != image.height: - raise ValidationError(_(u"The badge image must be square.")) - if not image.size < (250 * 1024): - raise ValidationError(_(u"The badge image file size must be less than 250KB.")) - - -class BadgeImageConfiguration(models.Model): - """ - Contains the configuration for badges for a specific mode. The mode - """ - class Meta(object): - app_label = "certificates" - - mode = models.CharField( - max_length=125, - help_text=_(u'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=_( - u"Badge images must be square PNG files. The file size should be under 250KB." - ), - upload_to='badges', - validators=[validate_badge_image] - ) - default = models.BooleanField( - default=False, - help_text=_( - u"Set this value to True if you want this image to be the default image for any course modes " - u"that do not have a specified badge image. You can have only one default image." - ) - ) - - def clean(self): - """ - Make sure there's not more than one default. - """ - if self.default and BadgeImageConfiguration.objects.filter(default=True).exclude(id=self.id): - raise ValidationError(_(u"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 CertificateTemplate(TimeStampedModel): """A set of custom web certificate templates. @@ -1095,6 +1012,28 @@ class CertificateTemplateAsset(TimeStampedModel): app_label = "certificates" +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 student.models import CourseEnrollment + mode = CourseEnrollment.objects.filter( + user=user, course_id=course_id + ).order_by('-is_active')[0].mode + course = modulestore().get_course(course_id) + return BadgeClass.get_badge_class( + slug=course_complete.course_slug(course_id, mode), + issuing_component='', + criteria=course_complete.criteria(course_id), + description=course_complete.badge_description(course, mode), + course_id=course_id, + mode=mode, + display_name=course.display_name, + image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode) + ) + + @receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) #pylint: disable=unused-argument def create_badge(sender, user, course_key, status, **kwargs): @@ -1106,14 +1045,14 @@ def create_badge(sender, user, course_key, status, **kwargs): if not modulestore().get_course(course_key).issue_badges: LOGGER.info("Course is not configured to issue badges.") return - if BadgeAssertion.objects.filter(user=user, course_id=course_key): - LOGGER.info("Badge already exists for this user on this course.") + badge_class = get_completion_badge(course_key, user) + 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 # Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this # by making sure it only gets run on the callback during normal workflow. if not status == CertificateStatuses.downloadable: return - from .badge_handler import BadgeHandler - handler = BadgeHandler(course_key) - handler.award(user) + evidence = course_complete.evidence_url(user.id, course_key) + badge_class.award(user, evidence_url=evidence) diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index 197d5efe2c..7b88cfe0e2 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -1,16 +1,14 @@ # Factories are self documenting # pylint: disable=missing-docstring -import factory from uuid import uuid4 -from django.core.files.base import ContentFile -from factory.django import DjangoModelFactory, ImageField -from student.models import LinkedInAddToProfileConfiguration +from factory.django import DjangoModelFactory from certificates.models import ( - GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion, - BadgeImageConfiguration, CertificateInvalidation, + GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, + CertificateInvalidation, ) +from student.models import LinkedInAddToProfileConfiguration class GeneratedCertificateFactory(DjangoModelFactory): @@ -44,33 +42,6 @@ class CertificateInvalidationFactory(DjangoModelFactory): active = True -class BadgeAssertionFactory(DjangoModelFactory): - class Meta(object): - model = BadgeAssertion - - mode = 'honor' - data = { - 'image': 'http://www.example.com/image.png', - 'json': {'id': 'http://www.example.com/assertion.json'}, - 'issuer': 'http://www.example.com/issuer.json', - } - - -class BadgeImageConfigurationFactory(DjangoModelFactory): - - class Meta(object): - model = BadgeImageConfiguration - - mode = 'honor' - icon = factory.LazyAttribute( - lambda _: ContentFile( - ImageField()._make_data( # pylint: disable=protected-access - {'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'} - ), 'test.png' - ) - ) - - class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): class Meta(object): diff --git a/lms/djangoapps/certificates/tests/test_cert_management.py b/lms/djangoapps/certificates/tests/test_cert_management.py index 71d0870a83..a0a791f882 100644 --- a/lms/djangoapps/certificates/tests/test_cert_management.py +++ b/lms/djangoapps/certificates/tests/test_cert_management.py @@ -8,12 +8,14 @@ from mock import patch from course_modes.models import CourseMode from opaque_keys.edx.locator import CourseLocator -from certificates.tests.factories import BadgeAssertionFactory + +from badges.models import BadgeAssertion +from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from student.tests.factories import UserFactory, CourseEnrollmentFactory from certificates.management.commands import resubmit_error_certificates, regenerate_user, ungenerated_certs -from certificates.models import GeneratedCertificate, CertificateStatuses, BadgeAssertion +from certificates.models import GeneratedCertificate, CertificateStatuses, get_completion_badge class CertificateManagementTest(ModuleStoreTestCase): @@ -30,6 +32,7 @@ class CertificateManagementTest(ModuleStoreTestCase): CourseFactory.create() for __ in range(3) ] + CourseCompleteImageConfigurationFactory.create() def _create_cert(self, course_key, user, status, mode=CourseMode.HONOR): """Create a certificate entry. """ @@ -170,9 +173,10 @@ class RegenerateCertificatesTest(CertificateManagementTest): And the badge will be deleted """ key = self.course.location.course_key - BadgeAssertionFactory(user=self.user, course_id=key, data={}) self._create_cert(key, self.user, CertificateStatuses.downloadable) - self.assertTrue(BadgeAssertion.objects.filter(user=self.user, course_id=key)) + badge_class = get_completion_badge(key, self.user) + BadgeAssertionFactory(badge_class=badge_class, user=self.user) + self.assertTrue(BadgeAssertion.objects.filter(user=self.user, badge_class=badge_class)) self._run_command( username=self.user.email, course=unicode(key), noop=False, insecure=False, template_file=None, grade_value=None @@ -185,7 +189,7 @@ class RegenerateCertificatesTest(CertificateManagementTest): template_file=None, generate_pdf=True ) - self.assertFalse(BadgeAssertion.objects.filter(user=self.user, course_id=key)) + self.assertFalse(BadgeAssertion.objects.filter(user=self.user, badge_class=badge_class)) @override_settings(CERT_QUEUE='test-queue') @patch('capa.xqueue_interface.XQueueInterface.send_to_queue', spec=True) diff --git a/lms/djangoapps/certificates/tests/test_models.py b/lms/djangoapps/certificates/tests/test_models.py index 2a1bc54e7a..3e25b6bb50 100644 --- a/lms/djangoapps/certificates/tests/test_models.py +++ b/lms/djangoapps/certificates/tests/test_models.py @@ -1,7 +1,6 @@ """Tests for certificate Django models. """ from django.conf import settings from django.core.exceptions import ValidationError -from django.core.files.images import ImageFile from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.test.utils import override_settings @@ -13,7 +12,6 @@ from certificates.models import ( ExampleCertificateSet, CertificateHtmlViewConfiguration, CertificateTemplateAsset, - BadgeImageConfiguration, EligibleCertificateManager, GeneratedCertificate, CertificateStatuses, @@ -167,55 +165,6 @@ class CertificateHtmlViewConfigurationTest(TestCase): self.assertEquals(self.config.get_config(), {}) -@attr('shard_1') -class BadgeImageConfigurationTest(TestCase): - """ - Test the validation features of BadgeImageConfiguration. - """ - def get_image(self, name): - """ - Get one of the test images from the test data directory. - """ - return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png')) - - def create_clean(self, file_obj): - """ - Shortcut to create a BadgeImageConfiguration with a specific file. - """ - BadgeImageConfiguration(mode='honor', icon=file_obj).full_clean() - - def test_good_image(self): - """ - Verify that saving a valid badge image is no problem. - """ - good = self.get_image('good') - BadgeImageConfiguration(mode='honor', icon=good).full_clean() - - def test_unbalanced_image(self): - """ - Verify that setting an image with an uneven width and height raises an error. - """ - unbalanced = ImageFile(self.get_image('unbalanced')) - self.assertRaises(ValidationError, self.create_clean, unbalanced) - - def test_large_image(self): - """ - Verify that setting an image that is too big raises an error. - """ - large = self.get_image('large') - self.assertRaises(ValidationError, self.create_clean, large) - - def test_no_double_default(self): - """ - Verify that creating two configurations as default is not permitted. - """ - BadgeImageConfiguration(mode='test', icon=self.get_image('good'), default=True).save() - self.assertRaises( - ValidationError, - BadgeImageConfiguration(mode='test2', icon=self.get_image('good'), default=True).full_clean - ) - - @attr('shard_1') class CertificateTemplateAssetTest(TestCase): """ diff --git a/lms/djangoapps/certificates/tests/test_views.py b/lms/djangoapps/certificates/tests/test_views.py index c252c61267..6824a1ff07 100644 --- a/lms/djangoapps/certificates/tests/test_views.py +++ b/lms/djangoapps/certificates/tests/test_views.py @@ -1,25 +1,17 @@ """Tests for certificates views. """ import json -import ddt from uuid import uuid4 -from nose.plugins.attrib import attr -from mock import patch +import ddt from django.conf import settings from django.core.cache import cache from django.core.urlresolvers import reverse from django.test import TestCase from django.test.client import Client from django.test.utils import override_settings - +from nose.plugins.attrib import attr from opaque_keys.edx.locator import CourseLocator -from openedx.core.lib.tests.assertions.events import assert_event_matches -from student.tests.factories import UserFactory -from track.tests import EventTrackingTestCase -from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from util.testing import UrlResetMixin from certificates.api import get_certificate_url from certificates.models import ( @@ -28,10 +20,9 @@ from certificates.models import ( GeneratedCertificate, CertificateHtmlViewConfiguration, ) - -from certificates.tests.factories import ( - BadgeAssertionFactory, -) +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True @@ -342,51 +333,3 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): self.assertNotIn('platform_microsite', response.content) self.assertNotIn('http://www.microsite.org', response.content) self.assertNotIn('This should not survive being overwritten by static content', response.content) - - -class TrackShareRedirectTest(UrlResetMixin, ModuleStoreTestCase, EventTrackingTestCase): - """ - Verifies the badge image share event is sent out. - """ - - @patch.dict(settings.FEATURES, {"ENABLE_OPENBADGES": True}) - def setUp(self): - super(TrackShareRedirectTest, self).setUp('certificates.urls') - self.client = Client() - self.course = CourseFactory.create( - org='testorg', number='run1', display_name='trackable course' - ) - self.assertion = BadgeAssertionFactory( - user=self.user, course_id=self.course.id, data={ - 'image': 'http://www.example.com/image.png', - 'json': {'id': 'http://www.example.com/assertion.json'}, - 'issuer': 'http://www.example.com/issuer.json' - }, - ) - - def test_social_event_sent(self): - test_url = '/certificates/badge_share_tracker/{}/social_network/{}/'.format( - unicode(self.course.id), - self.user.username, - ) - self.recreate_tracker() - response = self.client.get(test_url) - self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], 'http://www.example.com/image.png') - assert_event_matches( - { - 'name': 'edx.badge.assertion.shared', - 'data': { - 'course_id': 'testorg/run1/trackable_course', - 'social_network': 'social_network', - # pylint: disable=no-member - 'assertion_id': self.assertion.id, - 'assertion_json_url': 'http://www.example.com/assertion.json', - 'assertion_image_url': 'http://www.example.com/image.png', - 'user_id': self.user.id, - 'issuer': 'http://www.example.com/issuer.json', - 'enrollment_mode': 'honor' - }, - }, - self.get_event() - ) diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 78103266ba..a6b799eb9b 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -3,7 +3,6 @@ import json import ddt -import mock from uuid import uuid4 from nose.plugins.attrib import attr from mock import patch @@ -16,6 +15,7 @@ from django.test.client import Client from django.test.utils import override_settings from course_modes.models import CourseMode +from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory from openedx.core.lib.tests.assertions.events import assert_event_matches from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.roles import CourseStaffRole @@ -31,20 +31,21 @@ from certificates.models import ( CertificateTemplate, CertificateHtmlViewConfiguration, CertificateTemplateAsset, + get_completion_badge ) from certificates.tests.factories import ( CertificateHtmlViewConfigurationFactory, LinkedInAddToProfileConfigurationFactory, - BadgeAssertionFactory, GeneratedCertificateFactory, ) from util import organizations_helpers as organizations_api from django.test.client import RequestFactory -import urllib 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 @@ -105,6 +106,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ) CertificateHtmlViewConfigurationFactory.create() LinkedInAddToProfileConfigurationFactory.create() + CourseCompleteImageConfigurationFactory.create() def _add_course_certificates(self, count=1, signatory_count=0, is_active=True): """ @@ -333,7 +335,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ) self.assertIn('logo_test1.png', response.content) - @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) + @override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED) @patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", { "CERTIFICATE_TWITTER": True, "CERTIFICATE_FACEBOOK": True, @@ -370,8 +372,9 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): test_org = organizations_api.add_organization(organization_data=test_organization_data) organizations_api.add_organization_course(organization_data=test_org, course_id=unicode(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, course_id=self.course_id, + user=self.user, badge_class=badge_class, ) self.course.cert_html_view_overrides = { "logo_src": "/static/certificates/images/course_override_logo.png" @@ -812,8 +815,15 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ) test_url = '{}?evidence_visit=1'.format(cert_url) self.recreate_tracker() + badge_class = get_completion_badge(self.course_id, self.user) assertion = BadgeAssertionFactory.create( - user=self.user, course_id=self.course_id, + user=self.user, badge_class=badge_class, + backend='DummyBackend', + image_url='http://www.example.com/image.png', + assertion_url='http://www.example.com/assertion.json', + data={ + 'issuer': 'http://www.example.com/issuer.json', + } ) response = self.client.get(test_url) self.assertEqual(response.status_code, 200) @@ -823,6 +833,10 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): 'data': { 'course_id': 'testorg/run1/refundable_course', 'assertion_id': assertion.id, + 'badge_generator': u'DummyBackend', + 'badge_name': u'refundable course', + 'issuing_component': u'', + 'badge_slug': u'testorgrun1refundable_course_honor_432f164', 'assertion_json_url': 'http://www.example.com/assertion.json', 'assertion_image_url': 'http://www.example.com/image.png', 'user_id': self.user.id, diff --git a/lms/djangoapps/certificates/tests/tests.py b/lms/djangoapps/certificates/tests/tests.py index abd6401898..6a813d5a96 100644 --- a/lms/djangoapps/certificates/tests/tests.py +++ b/lms/djangoapps/certificates/tests/tests.py @@ -6,10 +6,11 @@ from mock import patch from django.conf import settings from nose.plugins.attrib import attr +from badges.tests.factories import CourseCompleteImageConfigurationFactory from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from student.tests.factories import UserFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory from certificates.models import ( CertificateStatuses, GeneratedCertificate, @@ -113,18 +114,18 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin): self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id)) @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) - @patch('certificates.badge_handler.BadgeHandler', spec=True) + @patch('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' ) - # Check return value since class instance will be stored there. - self.assertFalse(handler.return_value.award.called) cert.status = CertificateStatuses.downloadable cert.save() self.assertTrue(handler.return_value.award.called) diff --git a/lms/djangoapps/certificates/urls.py b/lms/djangoapps/certificates/urls.py index 74237e73f5..b5c4687294 100644 --- a/lms/djangoapps/certificates/urls.py +++ b/lms/djangoapps/certificates/urls.py @@ -31,15 +31,3 @@ urlpatterns = patterns( url(r'regenerate', views.regenerate_certificate_for_user, name="regenerate_certificate_for_user"), url(r'generate', views.generate_certificate_for_user, name="generate_certificate_for_user"), ) - - -if settings.FEATURES.get("ENABLE_OPENBADGES", False): - urlpatterns += ( - url( - r'^badge_share_tracker/{}/(?P[^/]+)/(?P[^/]+)/$'.format( - settings.COURSE_ID_PATTERN - ), - views.track_share_redirect, - name='badge_share_tracker' - ), - ) diff --git a/lms/djangoapps/certificates/views/__init__.py b/lms/djangoapps/certificates/views/__init__.py index 3b65c22cee..1b4b8cd4ae 100644 --- a/lms/djangoapps/certificates/views/__init__.py +++ b/lms/djangoapps/certificates/views/__init__.py @@ -5,4 +5,3 @@ Aggregate all views exposed by the certificates app. from .xqueue import * from .support import * from .webview import * -from .badges import * diff --git a/lms/djangoapps/certificates/views/badges.py b/lms/djangoapps/certificates/views/badges.py deleted file mode 100644 index db9ebceed7..0000000000 --- a/lms/djangoapps/certificates/views/badges.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Certificate views for open badges. -""" -from django.shortcuts import redirect, get_object_or_404 - -from opaque_keys.edx.locator import CourseLocator -from util.views import ensure_valid_course_key -from eventtracking import tracker -from certificates.models import BadgeAssertion - - -@ensure_valid_course_key -def track_share_redirect(request__unused, course_id, network, student_username): - """ - Tracks when a user downloads a badge for sharing. - """ - course_id = CourseLocator.from_string(course_id) - assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id) - tracker.emit( - 'edx.badge.assertion.shared', { - 'course_id': unicode(course_id), - 'social_network': network, - 'assertion_id': assertion.id, - 'assertion_json_url': assertion.data['json']['id'], - 'assertion_image_url': assertion.image_url, - 'user_id': assertion.user.id, - 'enrollment_mode': assertion.mode, - 'issuer': assertion.data['issuer'], - } - ) - return redirect(assertion.image_url) diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 4bd8dce563..2c76777bf7 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -13,7 +13,6 @@ from django.http import HttpResponse, Http404 from django.template import RequestContext from django.utils.translation import ugettext as _ from django.utils.encoding import smart_str -from django.core.urlresolvers import reverse from courseware.access import has_access from edxmako.shortcuts import render_to_response @@ -43,8 +42,8 @@ from certificates.models import ( CertificateStatuses, CertificateHtmlViewConfiguration, CertificateSocialNetworks, - BadgeAssertion -) + get_completion_badge) + log = logging.getLogger(__name__) @@ -355,21 +354,37 @@ def _track_certificate_events(request, context, course, user, user_certificate): """ Tracks web certificate view related events. """ - badge = context['badge'] # Badge Request Event Tracking Logic - if 'evidence_visit' in request.GET and badge: - tracker.emit( - 'edx.badge.assertion.evidence_visited', - { - 'user_id': user.id, - 'course_id': unicode(course.id), - 'enrollment_mode': badge.mode, - 'assertion_id': badge.id, - 'assertion_image_url': badge.data['image'], - 'assertion_json_url': badge.data['json']['id'], - 'issuer': badge.data['issuer'], - } - ) + course_key = course.location.course_key + + if 'evidence_visit' in request.GET: + badge_class = get_completion_badge(course_key, user) + 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': unicode(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.warn( + "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: @@ -425,10 +440,11 @@ def _update_badge_context(context, course, user): """ Updates context with badge info. """ - try: - badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key) - except BadgeAssertion.DoesNotExist: - badge = None + badge = None + if settings.FEATURES.get('ENABLE_OPENBADGES'): + badges = get_completion_badge(course.location.course_key, user).get_for_user(user) + if badges: + badge = badges[0] context['badge'] = badge diff --git a/lms/djangoapps/lms_xblock/test/test_runtime.py b/lms/djangoapps/lms_xblock/test/test_runtime.py index 46bfe72f23..5b852cc859 100644 --- a/lms/djangoapps/lms_xblock/test/test_runtime.py +++ b/lms/djangoapps/lms_xblock/test/test_runtime.py @@ -12,7 +12,6 @@ from opaque_keys.edx.locations import BlockUsageLocator, CourseLocator, SlashSep from lms.djangoapps.lms_xblock.runtime import quote_slashes, unquote_slashes, LmsModuleSystem from xblock.fields import ScopeIds from xmodule.modulestore.django import ModuleI18nService -from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xblock.exceptions import NoSuchServiceError diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 74d8f59a7e..f1f283ae53 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -290,6 +290,7 @@ MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) BADGR_API_TOKEN = ENV_TOKENS.get('BADGR_API_TOKEN', BADGR_API_TOKEN) BADGR_BASE_URL = ENV_TOKENS.get('BADGR_BASE_URL', BADGR_BASE_URL) BADGR_ISSUER_SLUG = ENV_TOKENS.get('BADGR_ISSUER_SLUG', BADGR_ISSUER_SLUG) +BADGR_TIMEOUT = ENV_TOKENS.get('BADGR_TIMEOUT', BADGR_TIMEOUT) # git repo loading environment GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos') diff --git a/lms/envs/common.py b/lms/envs/common.py index e7894f3896..fe16efabb2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2022,6 +2022,9 @@ INSTALLED_APPS = ( # Learner's dashboard 'learner_dashboard', + + # Needed whether or not enabled, due to migrations + 'badges', ) # Migrations which are not in the standard module "migrations" @@ -2265,12 +2268,17 @@ REGISTRATION_EMAIL_PATTERNS_ALLOWED = None CERT_NAME_SHORT = "Certificate" CERT_NAME_LONG = "Certificate of Achievement" -#################### Badgr OpenBadges generation ####################### +#################### OpenBadges Settings ####################### + +BADGING_BACKEND = 'badges.backends.badgr.BadgrBackend' + # Be sure to set up images for course modes using the BadgeImageConfiguration model in the certificates app. BADGR_API_TOKEN = None # Do not add the trailing slash here. BADGR_BASE_URL = "http://localhost:8005" BADGR_ISSUER_SLUG = "example-issuer" +# Number of seconds to wait on the badging server when contacting it before giving up. +BADGR_TIMEOUT = 10 ###################### Grade Downloads ###################### # These keys are used for all of our asynchronous downloadable files, including From 25958febbf03a8ef6fea33c2155891c1c983fa3d Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 10 Nov 2015 17:06:18 +0000 Subject: [PATCH 02/14] Add XBlock Badging Service. --- lms/djangoapps/badges/service.py | 11 +++ lms/djangoapps/badges/tests/test_models.py | 40 +++++----- lms/djangoapps/lms_xblock/runtime.py | 3 + .../lms_xblock/test/test_runtime.py | 76 +++++++++++++++++-- 4 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 lms/djangoapps/badges/service.py diff --git a/lms/djangoapps/badges/service.py b/lms/djangoapps/badges/service.py new file mode 100644 index 0000000000..05cc520795 --- /dev/null +++ b/lms/djangoapps/badges/service.py @@ -0,0 +1,11 @@ +""" +Badging service for XBlocks +""" +from badges.models import BadgeClass + + +class BadgingService(object): + """ + A class that provides functions for managing badges which XBlocks can use. + """ + get_badge_class = BadgeClass.get_badge_class diff --git a/lms/djangoapps/badges/tests/test_models.py b/lms/djangoapps/badges/tests/test_models.py index 0a17c92b53..a59ec41e2c 100644 --- a/lms/djangoapps/badges/tests/test_models.py +++ b/lms/djangoapps/badges/tests/test_models.py @@ -17,19 +17,15 @@ from certificates.tests.test_models import TEST_DATA_ROOT from student.tests.factories import UserFactory -class ImageFetchingMixin(object): +def get_image(name): """ - Provides the ability to grab a badge image from the test data root. + Get one of the test images from the test data directory. """ - def get_image(self, name): - """ - Get one of the test images from the test data directory. - """ - return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png')) + return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png')) @attr('shard_1') -class BadgeImageConfigurationTest(TestCase, ImageFetchingMixin): +class BadgeImageConfigurationTest(TestCase): """ Test the validation features of BadgeImageConfiguration. """ @@ -38,10 +34,10 @@ class BadgeImageConfigurationTest(TestCase, ImageFetchingMixin): """ Verify that creating two configurations as default is not permitted. """ - CourseCompleteImageConfiguration(mode='test', icon=self.get_image('good'), default=True).save() + CourseCompleteImageConfiguration(mode='test', icon=get_image('good'), default=True).save() self.assertRaises( ValidationError, - CourseCompleteImageConfiguration(mode='test2', icon=self.get_image('good'), default=True).full_clean + CourseCompleteImageConfiguration(mode='test2', icon=get_image('good'), default=True).full_clean ) def test_runs_validator(self): @@ -50,7 +46,7 @@ class BadgeImageConfigurationTest(TestCase, ImageFetchingMixin): """ self.assertRaises( ValidationError, - CourseCompleteImageConfiguration(mode='test2', icon=self.get_image('unbalanced')).full_clean + CourseCompleteImageConfiguration(mode='test2', icon=get_image('unbalanced')).full_clean ) @@ -60,7 +56,7 @@ class DummyBackend(object): """ -class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): +class BadgeClassTest(ModuleStoreTestCase): """ Test BadgeClass functionality """ @@ -80,7 +76,7 @@ class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): # Ignore additional parameters. This class already exists. badge_class = BadgeClass.get_badge_class( slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=self.get_image('good') + criteria='test', display_name='Testola', image_file_handle=get_image('good') ) # These defaults are set on the factory. self.assertEqual(badge_class.criteria, 'https://example.com/syllabus') @@ -97,11 +93,11 @@ class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): premade_badge_class = BadgeClassFactory.create(course_id=course_key) badge_class = BadgeClass.get_badge_class( slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=self.get_image('good') + criteria='test', display_name='Testola', image_file_handle=get_image('good') ) 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=self.get_image('good'), + criteria='test', display_name='Testola', image_file_handle=get_image('good'), course_id=course_key, ) self.assertNotEqual(badge_class.id, course_badge_class.id) @@ -114,7 +110,7 @@ class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): 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=self.get_image('good') + image_file_handle=get_image('good') ) # This should have been saved before being passed back. self.assertTrue(badge_class.id) @@ -152,7 +148,7 @@ class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): 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=self.get_image('unbalanced') + image_file_handle=get_image('unbalanced') ) def test_get_for_user(self): @@ -185,7 +181,7 @@ class BadgeClassTest(ModuleStoreTestCase, ImageFetchingMixin): ValidationError, BadgeClass( slug='test', issuing_component='test2', criteria='test3', - description='test4', image=self.get_image('unbalanced') + description='test4', image=get_image('unbalanced') ).full_clean ) @@ -221,7 +217,7 @@ class BadgeAssertionTest(ModuleStoreTestCase): self.assertEqual(course_scoped_assertions, course_assertions) -class ValidBadgeImageTest(TestCase, ImageFetchingMixin): +class ValidBadgeImageTest(TestCase): """ Tests the badge image field validator. """ @@ -229,18 +225,18 @@ class ValidBadgeImageTest(TestCase, ImageFetchingMixin): """ Verify that saving a valid badge image is no problem. """ - validate_badge_image(self.get_image('good')) + validate_badge_image(get_image('good')) def test_unbalanced_image(self): """ Verify that setting an image with an uneven width and height raises an error. """ - unbalanced = ImageFile(self.get_image('unbalanced')) + unbalanced = ImageFile(get_image('unbalanced')) self.assertRaises(ValidationError, validate_badge_image, unbalanced) def test_large_image(self): """ Verify that setting an image that is too big raises an error. """ - large = self.get_image('large') + large = get_image('large') self.assertRaises(ValidationError, validate_badge_image, large) diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py index 1206eef977..c06784115f 100644 --- a/lms/djangoapps/lms_xblock/runtime.py +++ b/lms/djangoapps/lms_xblock/runtime.py @@ -6,6 +6,7 @@ import re from django.core.urlresolvers import reverse from django.conf import settings +from badges.service import BadgingService 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 @@ -213,6 +214,8 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method ) services['settings'] = SettingsService() services['user_tags'] = UserTagsService(self) + if settings.FEATURES["ENABLE_OPENBADGES"]: + services['badging'] = BadgingService() self.request_token = kwargs.pop('request_token', None) super(LmsModuleSystem, self).__init__(**kwargs) diff --git a/lms/djangoapps/lms_xblock/test/test_runtime.py b/lms/djangoapps/lms_xblock/test/test_runtime.py index 5b852cc859..70f3fc56d1 100644 --- a/lms/djangoapps/lms_xblock/test/test_runtime.py +++ b/lms/djangoapps/lms_xblock/test/test_runtime.py @@ -5,16 +5,23 @@ Tests of the LMS XBlock Runtime and associated utilities from django.contrib.auth.models import User from django.conf import settings from ddt import ddt, data -from mock import Mock -from unittest import TestCase +from django.test import TestCase +from mock import Mock, patch from urlparse import urlparse + +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import BlockUsageLocator, CourseLocator, SlashSeparatedCourseKey + +from badges.tests.factories import BadgeClassFactory +from badges.tests.test_models import get_image from lms.djangoapps.lms_xblock.runtime import quote_slashes, unquote_slashes, LmsModuleSystem from xblock.fields import ScopeIds from xmodule.modulestore.django import ModuleI18nService from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xblock.exceptions import NoSuchServiceError +from student.tests.factories import UserFactory + TEST_STRINGS = [ '', 'foobar', @@ -141,9 +148,7 @@ class TestUserServiceAPI(TestCase): def setUp(self): super(TestUserServiceAPI, self).setUp() self.course_id = SlashSeparatedCourseKey("org", "course", "run") - - self.user = User(username='runtime_robot', email='runtime_robot@edx.org', password='test', first_name='Robot') - self.user.save() + self.user = UserFactory.create() def mock_get_real_user(_anon_id): """Just returns the test user""" @@ -186,6 +191,67 @@ class TestUserServiceAPI(TestCase): self.runtime.service(self.mock_block, 'user_tags').get_tag('fake_scope', self.key) +class TestBadgingService(TestCase): + """Test the badging service interface""" + + def setUp(self): + super(TestBadgingService, self).setUp() + self.course_id = CourseKey.from_string('course-v1:org+course+run') + + self.user = User(username='test_robot', email='test_robot@edx.org', password='test', first_name='Test') + self.user.save() + + self.mock_block = Mock() + self.mock_block.service_declaration.return_value = 'needs' + + def create_runtime(self): + """ + Create the testing runtime. + """ + def mock_get_real_user(_anon_id): + """Just returns the test user""" + return self.user + + return LmsModuleSystem( + static_url='/static', + track_function=Mock(), + get_module=Mock(), + render_template=Mock(), + replace_urls=str, + course_id=self.course_id, + get_real_user=mock_get_real_user, + descriptor_runtime=Mock(), + ) + + @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) + def test_service_rendered(self): + runtime = self.create_runtime() + self.assertTrue(runtime.service(self.mock_block, 'badging')) + + @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': False}) + def test_no_service_rendered(self): + runtime = self.create_runtime() + self.assertFalse(runtime.service(self.mock_block, 'badging')) + + @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) + def test_get_badge_class(self): + runtime = self.create_runtime() + badge_service = runtime.service(self.mock_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. + 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=get_image('good') + ) + # These defaults are set on the factory. + self.assertEqual(badge_class.criteria, 'https://example.com/syllabus') + self.assertEqual(badge_class.display_name, 'Test Badge') + self.assertEqual(badge_class.description, "Yay! It's a test badge.") + # File name won't always be the same. + self.assertEqual(badge_class.image.path, premade_badge_class.image.path) + + class TestI18nService(ModuleStoreTestCase): """ Test ModuleI18nService """ From 61c76771f86c782d4d73fbbb74f9e62b62d74bb3 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Thu, 12 Nov 2015 21:07:42 +0000 Subject: [PATCH 03/14] Add completion and enrollment badges. --- common/djangoapps/student/models.py | 5 + lms/djangoapps/badges/admin.py | 5 +- .../badges/events/course_complete.py | 54 +++- lms/djangoapps/badges/events/course_meta.py | 78 ++++++ .../badges/events/tests/test_course_meta.py | 233 ++++++++++++++++++ .../0003_schema__add_event_configuration.py | 33 +++ lms/djangoapps/badges/models.py | 116 ++++++++- lms/djangoapps/badges/tests/factories.py | 12 +- lms/djangoapps/badges/tests/test_models.py | 21 +- lms/djangoapps/badges/utils.py | 14 ++ .../management/commands/regenerate_user.py | 2 +- lms/djangoapps/certificates/models.py | 58 ++--- .../tests/test_cert_management.py | 3 +- .../certificates/tests/test_webview_views.py | 5 +- lms/djangoapps/certificates/views/webview.py | 4 +- 15 files changed, 589 insertions(+), 54 deletions(-) create mode 100644 lms/djangoapps/badges/events/course_meta.py create mode 100644 lms/djangoapps/badges/events/tests/test_course_meta.py create mode 100644 lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 8ff43b4456..765a75b3a0 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -22,6 +22,7 @@ from urllib import urlencode import uuid import analytics + from config_models.models import ConfigurationModel from django.utils.translation import ugettext_lazy as _ from django.conf import settings @@ -1212,6 +1213,10 @@ 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"): + from lms.djangoapps.badges.events.course_meta import award_enrollment_badge + award_enrollment_badge(user) + return enrollment @classmethod diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py index a30eaf4f9c..08b0c59da3 100644 --- a/lms/djangoapps/badges/admin.py +++ b/lms/djangoapps/badges/admin.py @@ -2,6 +2,9 @@ Admin registration for Badge Models """ from django.contrib import admin -from badges.models import CourseCompleteImageConfiguration +from badges.models import CourseCompleteImageConfiguration, CourseEventBadgesConfiguration, BadgeClass +from config_models.admin import ConfigurationModelAdmin admin.site.register(CourseCompleteImageConfiguration) +admin.site.register(BadgeClass) +admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin) diff --git a/lms/djangoapps/badges/events/course_complete.py b/lms/djangoapps/badges/events/course_complete.py index 1c9fd22fad..c2786a3b52 100644 --- a/lms/djangoapps/badges/events/course_complete.py +++ b/lms/djangoapps/badges/events/course_complete.py @@ -2,17 +2,23 @@ Helper functions for the course complete event that was originally included with the Badging MVP. """ import hashlib +import logging from django.core.urlresolvers import reverse from django.template.defaultfilters import slugify from django.utils.translation import ugettext_lazy as _ -from badges.utils import site_prefix +from badges.models import CourseCompleteImageConfiguration, BadgeClass, BadgeAssertion +from badges.utils import site_prefix, requires_badges_enabled +from xmodule.modulestore.django import modulestore + +LOGGER = logging.getLogger(__name__) # NOTE: As these functions are carry-overs from the initial badging implementation, they are used in # migrations. Please check the badge migrations when changing any of these functions. + 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 @@ -61,3 +67,49 @@ def criteria(course_key): """ about_path = reverse('about_course', kwargs={'course_id': unicode(course_key)}) return u'{}{}'.format(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 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) + 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) diff --git a/lms/djangoapps/badges/events/course_meta.py b/lms/djangoapps/badges/events/course_meta.py new file mode 100644 index 0000000000..a09696a1d0 --- /dev/null +++ b/lms/djangoapps/badges/events/course_meta.py @@ -0,0 +1,78 @@ +""" +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 badges.models import CourseEventBadgesConfiguration, BadgeClass +from 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. + """ + slug = config.get(count) + if not slug: + return + badge_class = BadgeClass.get_badge_class( + slug=slug, issuing_component='edx__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 certificates.models 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 certificates.models 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='edx__course', create=False, + ) + if badge_class and not badge_class.get_for_user(user): + badge_class.award(user) diff --git a/lms/djangoapps/badges/events/tests/test_course_meta.py b/lms/djangoapps/badges/events/tests/test_course_meta.py new file mode 100644 index 0000000000..c5c92fe828 --- /dev/null +++ b/lms/djangoapps/badges/events/tests/test_course_meta.py @@ -0,0 +1,233 @@ +""" +Tests the course meta badging events +""" + +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 certificates.models import GeneratedCertificate, CertificateStatuses +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +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) + + +@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) +@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +class CourseEnrollmentBadgeTest(ModuleStoreTestCase): + """ + Tests the event which awards badges based on number of courses a user is enrolled in. + """ + def setUp(self): + super(CourseEnrollmentBadgeTest, self).setUp() + self.badge_classes = [ + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__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() + # pylint: disable=no-member + CourseEnrollment.enroll(user, course_key=course.location.course_key) + self.assertFalse(user.badgeassertion_set.all()) + + def test_checkpoint_matches(self): + """ + Make sure the proper badges are awarded at the right checkpoints. + """ + user = UserFactory() + courses = [CourseFactory() for _i in range(3)] + 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]) + + +@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) +@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +class CourseCompletionBadgeTest(ModuleStoreTestCase): + """ + Tests the event which awards badges based on the number of courses completed. + """ + def setUp(self, **kwargs): + super(CourseCompletionBadgeTest, self).setUp() + self.badge_classes = [ + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__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( + # pylint: disable=no-member + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + self.assertFalse(user.badgeassertion_set.all()) + + def test_checkpoint_matches(self): + """ + Make sure the proper badges are awarded at the right checkpoints. + """ + user = UserFactory() + courses = [CourseFactory() for _i in range(2)] + 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]) + + +@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) +@override_settings(BADGING_BACKEND='lms.djangoapps.badges.events.tests.test_course_meta.DummyBackend') +class CourseGroupBadgeTest(ModuleStoreTestCase): + """ + Tests the event which awards badges when a user completes a set of courses. + """ + def setUp(self): + super(CourseGroupBadgeTest, self).setUp() + self.badge_classes = [ + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + RandomBadgeClassFactory( + issuing_component='edx__course' + ), + ] + self.courses = [] + for _badge_class in self.badge_classes: + # pylint: disable=no-member + self.courses.append([CourseFactory().location.course_key for _i in range(3)]) + lines = [badge_class.slug + ',' + ','.join([unicode(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(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( + # pylint: disable=no-member + user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable + ).save() + # pylint: disable=no-member + self.assertFalse(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): + self.assertTrue(badge_class.get_for_user(user)) + else: + self.assertFalse(badge_class.get_for_user(user)) + # pylint: disable=no-member + classes = [badge.badge_class.id for badge in user.badgeassertion_set.all()] + source_classes = [badge.id for badge in self.badge_classes] + self.assertEqual(classes, source_classes) diff --git a/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py b/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py new file mode 100644 index 0000000000..aefbf1d3fb --- /dev/null +++ b/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('badges', '0002_data__migrate_assertions'), + ] + + operations = [ + migrations.CreateModel( + name='CourseEventBadgesConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('courses_completed', models.TextField(default=b'', 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 with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), + ('courses_enrolled', models.TextField(default=b'', 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 with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), + ('course_groups', models.TextField(default=b'', help_text="On each line, put the slug of a badge class you have created with the issuing component 'edx__course' to award, a comma, and a comma separated list of course keys that the user will need to complete to get this badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + ), + migrations.AlterModelOptions( + name='badgeclass', + options={'verbose_name_plural': 'Badge Classes'}, + ), + ] diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 909ccaec1e..a1ad274c6c 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -9,7 +9,11 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ from lazy import lazy +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from config_models.models import ConfigurationModel +from xmodule.modulestore.django import modulestore from xmodule_django.models import CourseKeyField from jsonfield import JSONField @@ -53,7 +57,7 @@ class BadgeClass(models.Model): @classmethod def get_badge_class( - cls, slug, issuing_component, display_name, description, criteria, image_file_handle, + cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None, mode='', course_id=None, create=True ): """ @@ -115,6 +119,7 @@ class BadgeClass(models.Model): class Meta(object): app_label = "badges" unique_together = (('slug', 'issuing_component', 'course_id'),) + verbose_name_plural = "Badge Classes" class BadgeAssertion(models.Model): @@ -199,3 +204,112 @@ class CourseCompleteImageConfiguration(models.Model): class Meta(object): 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. + """ + courses_completed = models.TextField( + blank=True, default='', + help_text=_( + u"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a " + u"badge class you have created with the issuing component 'edx__course'. " + u"For example: 3,course-v1:edx/Demo/DemoX" + ) + ) + courses_enrolled = models.TextField( + blank=True, default='', + help_text=_( + u"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a " + u"badge class you have created with the issuing component 'edx__course'. " + u"For example: 3,course-v1:edx/Demo/DemoX" + ) + ) + course_groups = models.TextField( + blank=True, default='', + help_text=_( + u"Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, " + u"with an issuing component of 'edx__course'. The remaining items in each line are the course keys the " + u"user will need to complete to get the badge. For example: slug_for_compsci_courses_group_badge,course-v1" + u":CompSci+Course+First,course-v1:CompsSci+Course+Second" + ) + ) + + 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) + + @property + def enrolled_settings(self): + """ + Parses the settings from the courses_completed field. + """ + return self.get_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 = _(u"Please check the syntax of your entry.") + if 'courses_completed' not in exclude: + try: + self.completed_settings + except (ValueError, InvalidKeyError): + errors['courses_completed'] = [unicode(error_message)] + if 'courses_enrolled' not in exclude: + try: + self.enrolled_settings + except (ValueError, InvalidKeyError): + errors['courses_enrolled'] = [unicode(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(u"The course {course_key} does not exist.".format(course_key=course_key)) + except (ValueError, InvalidKeyError): + errors['course_groups'] = [unicode(error_message)] + if errors: + raise ValidationError(errors) + + class Meta(object): + app_label = "badges" diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py index dfb60544df..4751ed413b 100644 --- a/lms/djangoapps/badges/tests/factories.py +++ b/lms/djangoapps/badges/tests/factories.py @@ -8,7 +8,7 @@ from django.core.files.base import ContentFile from factory import DjangoModelFactory from factory.django import ImageField -from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass +from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass, CourseEventBadgesConfiguration from student.tests.factories import UserFactory @@ -69,3 +69,13 @@ class BadgeAssertionFactory(DjangoModelFactory): data = {} assertion_url = 'http://example.com/example.json' image_url = 'http://example.com/image.png' + + +class CourseEventBadgesConfigurationFactory(DjangoModelFactory): + """ + Factory for CourseEventsBadgesConfiguration + """ + class Meta(object): + model = CourseEventBadgesConfiguration + + enabled = True diff --git a/lms/djangoapps/badges/tests/test_models.py b/lms/djangoapps/badges/tests/test_models.py index a59ec41e2c..6058ce0ee2 100644 --- a/lms/djangoapps/badges/tests/test_models.py +++ b/lms/djangoapps/badges/tests/test_models.py @@ -3,9 +3,10 @@ Tests for the Badges app models. """ from django.core.exceptions import ValidationError from django.core.files.images import ImageFile +from django.db.utils import IntegrityError from django.test import TestCase from django.test.utils import override_settings -from mock import patch +from mock import patch, Mock from nose.plugins.attrib import attr from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -54,6 +55,7 @@ class DummyBackend(object): """ Dummy badge backend, used for testing. """ + award = Mock() class BadgeClassTest(ModuleStoreTestCase): @@ -126,9 +128,7 @@ class BadgeClassTest(ModuleStoreTestCase): Test returns None if the badge class does not exist. """ 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 + slug='new_slug', issuing_component='new_component', create=False ) self.assertIsNone(badge_class) # Run this twice to verify there wasn't a background creation of the badge. @@ -139,7 +139,7 @@ class BadgeClassTest(ModuleStoreTestCase): ) self.assertIsNone(badge_class) - def test_get_badge_class_validate(self): + def test_get_badge_class_image_validate(self): """ Verify handing a broken image to get_badge_class raises a validation error upon creation. """ @@ -151,6 +151,17 @@ class BadgeClassTest(ModuleStoreTestCase): image_file_handle=get_image('unbalanced') ) + def test_get_badge_class_data_validate(self): + """ + Verify handing incomplete data for required fields when making a badge class raises an Integrity error. + """ + self.assertRaises( + IntegrityError, + BadgeClass.get_badge_class, + slug='new_slug', issuing_component='new_component', + image_file_handle=get_image('good') + ) + def test_get_for_user(self): """ Make sure we can get an assertion for a user if there is one. diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py index 9e2d98f304..4c069d3a72 100644 --- a/lms/djangoapps/badges/utils.py +++ b/lms/djangoapps/badges/utils.py @@ -10,3 +10,17 @@ def site_prefix(): """ scheme = u"https" if settings.HTTPS == "on" else u"http" return u'{}://{}'.format(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 settings.FEATURES.get('ENABLE_OPENBADGES', False): + return + return function(*args, **kwargs) + return wrapped diff --git a/lms/djangoapps/certificates/management/commands/regenerate_user.py b/lms/djangoapps/certificates/management/commands/regenerate_user.py index 7461a9654b..ac37abf34a 100644 --- a/lms/djangoapps/certificates/management/commands/regenerate_user.py +++ b/lms/djangoapps/certificates/management/commands/regenerate_user.py @@ -9,7 +9,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey -from certificates.models import get_completion_badge +from badges.events.course_complete import get_completion_badge from xmodule.modulestore.django import modulestore from certificates.api import regenerate_user_certificates diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 9de0d2a59f..42da820c1c 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -64,12 +64,11 @@ from model_utils.models import TimeStampedModel from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED -from badges.events import course_complete -from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass +from badges.events.course_complete import course_badge_check +from badges.events.course_meta import completion_check, course_group_check from config_models.models import ConfigurationModel from instructor_task.models import InstructorTask from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled -from xmodule.modulestore.django import modulestore from xmodule_django.models import CourseKeyField, NoneToEmptyManager LOGGER = logging.getLogger(__name__) @@ -1012,47 +1011,26 @@ class CertificateTemplateAsset(TimeStampedModel): app_label = "certificates" -def get_completion_badge(course_id, user): +@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) +# pylint: disable=unused-argument +def create_course_badge(sender, user, course_key, status, **kwargs): """ - Given a course key and a user, find the user's enrollment mode - and get the Course Completion badge. + Standard signal hook to create course badges when a certificate has been generated. """ - from student.models import CourseEnrollment - mode = CourseEnrollment.objects.filter( - user=user, course_id=course_id - ).order_by('-is_active')[0].mode - course = modulestore().get_course(course_id) - return BadgeClass.get_badge_class( - slug=course_complete.course_slug(course_id, mode), - issuing_component='', - criteria=course_complete.criteria(course_id), - description=course_complete.badge_description(course, mode), - course_id=course_id, - mode=mode, - display_name=course.display_name, - image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode) - ) + course_badge_check(user, course_key) @receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) -#pylint: disable=unused-argument -def create_badge(sender, user, course_key, status, **kwargs): +def create_completion_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument """ - Standard signal hook to create badges when a certificate has been generated. + Standard signal hook to create 'x courses completed' badges when a certificate has been generated. """ - if not settings.FEATURES.get('ENABLE_OPENBADGES', False): - return - 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 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 - # Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this - # by making sure it only gets run on the callback during normal workflow. - if not status == CertificateStatuses.downloadable: - return - evidence = course_complete.evidence_url(user.id, course_key) - badge_class.award(user, evidence_url=evidence) + 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) diff --git a/lms/djangoapps/certificates/tests/test_cert_management.py b/lms/djangoapps/certificates/tests/test_cert_management.py index a0a791f882..413158616d 100644 --- a/lms/djangoapps/certificates/tests/test_cert_management.py +++ b/lms/djangoapps/certificates/tests/test_cert_management.py @@ -9,13 +9,14 @@ from mock import patch from course_modes.models import CourseMode from opaque_keys.edx.locator import CourseLocator +from badges.events.course_complete import get_completion_badge from badges.models import BadgeAssertion from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from student.tests.factories import UserFactory, CourseEnrollmentFactory from certificates.management.commands import resubmit_error_certificates, regenerate_user, ungenerated_certs -from certificates.models import GeneratedCertificate, CertificateStatuses, get_completion_badge +from certificates.models import GeneratedCertificate, CertificateStatuses class CertificateManagementTest(ModuleStoreTestCase): diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index a6b799eb9b..7c80987f77 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -14,7 +14,11 @@ from django.core.urlresolvers import reverse from django.test.client import Client from django.test.utils import override_settings +<<<<<<< HEAD from course_modes.models import CourseMode +======= +from badges.events.course_complete import get_completion_badge +>>>>>>> a248c5a... Add completion and enrollment badges. from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory from openedx.core.lib.tests.assertions.events import assert_event_matches from student.tests.factories import UserFactory, CourseEnrollmentFactory @@ -31,7 +35,6 @@ from certificates.models import ( CertificateTemplate, CertificateHtmlViewConfiguration, CertificateTemplateAsset, - get_completion_badge ) from certificates.tests.factories import ( diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 2c76777bf7..eb8441af35 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -14,6 +14,7 @@ from django.template import RequestContext from django.utils.translation import ugettext as _ from django.utils.encoding import smart_str +from badges.events.course_complete import get_completion_badge from courseware.access import has_access from edxmako.shortcuts import render_to_response from edxmako.template import Template @@ -41,8 +42,7 @@ from certificates.models import ( GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, - CertificateSocialNetworks, - get_completion_badge) + CertificateSocialNetworks) log = logging.getLogger(__name__) From a2104634a121c1253141c5e739de9f66e1797d20 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 24 Nov 2015 18:50:14 +0000 Subject: [PATCH 04/14] Implement Badging API views. --- lms/djangoapps/badges/api/__init__.py | 0 lms/djangoapps/badges/api/serializers.py | 28 +++ lms/djangoapps/badges/api/tests.py | 233 ++++++++++++++++++ lms/djangoapps/badges/api/urls.py | 12 + lms/djangoapps/badges/api/views.py | 121 +++++++++ lms/djangoapps/badges/models.py | 2 +- lms/djangoapps/badges/tests/factories.py | 2 +- lms/urls.py | 5 + .../djangoapps/user_api/tests/test_views.py | 103 ++------ openedx/core/lib/api/test_utils.py | 78 ++++++ 10 files changed, 496 insertions(+), 88 deletions(-) create mode 100644 lms/djangoapps/badges/api/__init__.py create mode 100644 lms/djangoapps/badges/api/serializers.py create mode 100644 lms/djangoapps/badges/api/tests.py create mode 100644 lms/djangoapps/badges/api/urls.py create mode 100644 lms/djangoapps/badges/api/views.py create mode 100644 openedx/core/lib/api/test_utils.py diff --git a/lms/djangoapps/badges/api/__init__.py b/lms/djangoapps/badges/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py new file mode 100644 index 0000000000..65c2beaca8 --- /dev/null +++ b/lms/djangoapps/badges/api/serializers.py @@ -0,0 +1,28 @@ +""" +Serializers for Badges +""" +from rest_framework import serializers + +from badges.models import BadgeClass, BadgeAssertion + + +class BadgeClassSerializer(serializers.ModelSerializer): + """ + Serializer for BadgeClass model. + """ + image_url = serializers.ImageField(source='image') + + class Meta(object): + 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(object): + model = BadgeAssertion + fields = ('badge_class', 'image_url', 'assertion_url') diff --git a/lms/djangoapps/badges/api/tests.py b/lms/djangoapps/badges/api/tests.py new file mode 100644 index 0000000000..7b45902502 --- /dev/null +++ b/lms/djangoapps/badges/api/tests.py @@ -0,0 +1,233 @@ +""" +Tests for the badges API views. +""" +from django.conf import settings +from django.test.utils import override_settings + +from badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory +from openedx.core.lib.api.test_utils import ApiTestCase +from student.tests.factories import UserFactory +from util.testing import UrlResetMixin +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +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. + """ + WILDCARD = False + CHECK_COURSE = False + + def setUp(self, *args, **kwargs): + super(UserAssertionTestCase, self).setUp(*args, **kwargs) + self.course = CourseFactory.create() + self.user = UserFactory.create() + # Password defined by factory. + self.client.login(username=self.user.username, password='test') + + def url(self): + """ + Return the URL to look up the current user's assertions. + """ + return '/api/badges/v1/assertions/user/{}/'.format(self.user.username) + + def check_class_structure(self, badge_class, json_class): + """ + Check a JSON response against a known badge class. + """ + self.assertEqual(badge_class.issuing_component, json_class['issuing_component']) + self.assertEqual(badge_class.slug, json_class['slug']) + self.assertIn(badge_class.image.url, json_class['image_url']) + self.assertEqual(badge_class.description, json_class['description']) + self.assertEqual(badge_class.criteria, json_class['criteria']) + self.assertEqual(badge_class.course_id and unicode(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. + """ + self.assertEqual(assertion.image_url, json_assertion['image_url']) + 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): + """ + Used for tests which may need to test for a course_id or a wildcard. + """ + if self.WILDCARD: + return '*' + else: + return unicode(badge_class.course_id) + + def create_badge_class(self, **kwargs): + """ + Create a badge class, using a course id if it's relevant to the URL pattern. + """ + if self.CHECK_COURSE: + return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs) + return RandomBadgeClassFactory.create(**kwargs) + + def get_qs_args(self, 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 self.CHECK_COURSE: + qs_args['course_id'] = self.get_course_id(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() + 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() + assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class) + response = self.get_json(self.url()) + # pylint: disable=no-member + self.check_assertion_structure(assertion, response['results'][0]) + + +class TestUserCourseBadgeAssertions(UserAssertionTestCase): + """ + Test the Badge Assertions view with the course_id filter. + """ + CHECK_COURSE = True + + 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. + for dummy in range(3): + BadgeAssertionFactory.create(user=self.user) + # Also should not be included + for dummy in range(6): + BadgeAssertionFactory.create(badge_class=badge_class) + response = self.get_json(self.url(), data={'course_id': course_key}) + # pylint: disable=no-member + self.assertEqual(len(response['results']), 3) + unused_course = CourseFactory.create() + response = self.get_json(self.url(), data={'course_id': unused_course.location.course_key}) + # pylint: disable=no-member + self.assertEqual(len(response['results']), 0) + + def test_assertion_structure(self): + """ + Verify the badge assertion structure is not mangled in this mode. + """ + 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()) + # pylint: disable=no-member + self.check_assertion_structure(assertion, response['results'][0]) + + +class TestUserBadgeAssertionsByClass(UserAssertionTestCase): + """ + Test the Badge Assertions view with the badge class filter. + """ + + def test_get_assertions(self): + """ + Verify we can get assertions via the badge class and username. + """ + badge_class = self.create_badge_class() + 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) + # Should not be in list. + for dummy in range(5): + BadgeAssertionFactory.create(badge_class=badge_class) + # Also should not be in list. + for dummy in range(6): + BadgeAssertionFactory.create() + + response = self.get_json( + self.url(), + data=self.get_qs_args(badge_class), + ) + if self.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') + + response = self.get_json( + self.url(), + data=self.get_qs_args(unused_class), + ) + # pylint: disable=no-member + self.assertEqual(len(response['results']), 0) + + def check_badge_class_assertion(self, 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), + ) + # 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()) + + 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 diff --git a/lms/djangoapps/badges/api/urls.py b/lms/djangoapps/badges/api/urls.py new file mode 100644 index 0000000000..1164cc9033 --- /dev/null +++ b/lms/djangoapps/badges/api/urls.py @@ -0,0 +1,12 @@ +""" +URLs for badges API +""" +from django.conf import settings +from django.conf.urls import patterns, url + +from .views import UserBadgeAssertions + +urlpatterns = patterns( + 'badges.api', + url('^assertions/user/' + settings.USERNAME_PATTERN + '/$', UserBadgeAssertions.as_view(), name='user_assertions'), +) diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py new file mode 100644 index 0000000000..ec18442531 --- /dev/null +++ b/lms/djangoapps/badges/api/views.py @@ -0,0 +1,121 @@ +""" +API views for badges +""" +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import generics +from rest_framework.exceptions import APIException + +from badges.models import BadgeAssertion +from openedx.core.lib.api.view_utils import view_auth_classes +from .serializers import BadgeAssertionSerializer +from xmodule_django.models import CourseKeyField + + +class CourseKeyError(APIException): + """ + Raised the course key given isn't valid. + """ + status_code = 400 + default_detail = "The course key provided could not be parsed." + + +@view_auth_classes(is_user=True) +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": "edx__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 + + 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 CourseKeyError + elif 'slug' not in self.request.query_params: + # Need to get all badges for the user. + course_id = None + else: + 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 diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index a1ad274c6c..19c8eecd44 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -32,7 +32,7 @@ def validate_lowercase(string): """ Validates that a string is lowercase. """ - if not string == string.lower(): + if not string.islower(): raise ValidationError(_(u"This value must be all lowercase.")) diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py index 4751ed413b..c625454a9b 100644 --- a/lms/djangoapps/badges/tests/factories.py +++ b/lms/djangoapps/badges/tests/factories.py @@ -54,7 +54,7 @@ class RandomBadgeClassFactory(BadgeClassFactory): """ Same as BadgeClassFactory, but randomize the slug. """ - slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random())) + slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()).replace('.', '_')) class BadgeAssertionFactory(DjangoModelFactory): diff --git a/lms/urls.py b/lms/urls.py index 387982daa2..9b1261377b 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -136,6 +136,11 @@ if settings.FEATURES["ENABLE_MOBILE_REST_API"]: url(r'^api/mobile/v0.5/', include('mobile_api.urls')), ) +if settings.FEATURES["ENABLE_OPENBADGES"]: + urlpatterns += ( + url(r'^api/badges/v1/', include('badges.api.urls')), + ) + js_info_dict = { 'domain': 'djangojs', # We need to explicitly include external Django apps that are not in LOCALE_PATHS. diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index ad2919845f..c39e277aa1 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1,79 +1,53 @@ """Tests for the user API at the HTTP request level. """ import datetime -import base64 import json -import re from unittest import skipUnless, SkipTest import ddt import httpretty -from pytz import UTC import mock - from django.conf import settings -from django.core.urlresolvers import reverse -from django.core import mail from django.contrib.auth.models import User -from django.test import TestCase +from django.core import mail +from django.core.urlresolvers import reverse +from django.test.client import RequestFactory from django.test.testcases import TransactionTestCase from django.test.utils import override_settings -from django.test.client import RequestFactory - +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from pytz import UTC from social.apps.django_app.default.models import UserSocialAuth -from opaque_keys.edx.locations import SlashSeparatedCourseKey - from django_comment_common import models +from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY from student.tests.factories import UserFactory from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from third_party_auth.tests.utils import ( ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle ) from .test_helpers import TestCaseForm -from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from ..accounts.api import get_account_settings +from xmodule.modulestore.tests.factories import CourseFactory from ..accounts import ( NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH ) +from ..accounts.api import get_account_settings from ..models import UserOrgTag from ..tests.factories import UserPreferenceFactory from ..tests.test_constants import SORTED_COUNTRIES - -TEST_API_KEY = "test_api_key" USER_LIST_URI = "/user_api/v1/users/" USER_PREFERENCE_LIST_URI = "/user_api/v1/user_prefs/" ROLE_LIST_URI = "/user_api/v1/forum_roles/Moderator/users/" -@override_settings(EDX_API_KEY=TEST_API_KEY) -class ApiTestCase(TestCase): +class UserAPITestCase(ApiTestCase): """ - Parent test case for API workflow coverage + Parent test case for User API workflow coverage """ - LIST_URI = USER_LIST_URI - def basic_auth(self, username, password): - """ - Returns a dictionary containing the http auth header with encoded username+password - """ - return {'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('%s:%s' % (username, password))} - - def request_with_auth(self, method, *args, **kwargs): - """Issue a get request to the given URI with the API key header""" - return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs) - - def get_json(self, *args, **kwargs): - """Make a request with the given args and return the parsed JSON repsonse""" - resp = self.request_with_auth("get", *args, **kwargs) - self.assertHttpOK(resp) - self.assertTrue(resp["Content-Type"].startswith("application/json")) - return json.loads(resp.content) - def get_uri_for_user(self, target_user): """Given a user object, get the URI for the corresponding resource""" users = self.get_json(USER_LIST_URI)["results"] @@ -90,20 +64,6 @@ class ApiTestCase(TestCase): return pref["url"] self.fail() - def assertAllowedMethods(self, uri, expected_methods): - """Assert that the allowed methods for the given URI match the expected list""" - resp = self.request_with_auth("options", uri) - self.assertHttpOK(resp) - allow_header = resp.get("Allow") - self.assertIsNotNone(allow_header) - allowed_methods = re.split('[^A-Z]+', allow_header) - self.assertItemsEqual(allowed_methods, expected_methods) - - def assertSelfReferential(self, obj): - """Assert that accessing the "url" entry in the given object returns the same object""" - copy = self.get_json(obj["url"]) - self.assertEqual(obj, copy) - def assertUserIsValid(self, user): """Assert that the given user result is valid""" self.assertItemsEqual(user.keys(), ["email", "id", "name", "username", "preferences", "url"]) @@ -121,37 +81,8 @@ class ApiTestCase(TestCase): self.assertSelfReferential(pref) self.assertUserIsValid(pref["user"]) - def assertHttpOK(self, response): - """Assert that the given response has the status code 200""" - self.assertEqual(response.status_code, 200) - def assertHttpForbidden(self, response): - """Assert that the given response has the status code 403""" - self.assertEqual(response.status_code, 403) - - def assertHttpBadRequest(self, response): - """Assert that the given response has the status code 400""" - self.assertEqual(response.status_code, 400) - - def assertHttpMethodNotAllowed(self, response): - """Assert that the given response has the status code 405""" - self.assertEqual(response.status_code, 405) - - def assertAuthDisabled(self, method, uri): - """ - Assert that the Django rest framework does not interpret basic auth - headers for views exposed to anonymous users as an attempt to authenticate. - - """ - # Django rest framework interprets basic auth headers - # as an attempt to authenticate with the API. - # We don't want this for views available to anonymous users. - basic_auth_header = "Basic " + base64.b64encode('username:password') - response = getattr(self.client, method)(uri, HTTP_AUTHORIZATION=basic_auth_header) - self.assertNotEqual(response.status_code, 403) - - -class EmptyUserTestCase(ApiTestCase): +class EmptyUserTestCase(UserAPITestCase): """ Test that the endpoint supports empty user result sets """ @@ -163,7 +94,7 @@ class EmptyUserTestCase(ApiTestCase): self.assertEqual(result["results"], []) -class EmptyRoleTestCase(ApiTestCase): +class EmptyRoleTestCase(UserAPITestCase): """Test that the endpoint supports empty result sets""" course_id = SlashSeparatedCourseKey.from_deprecated_string("org/course/run") LIST_URI = ROLE_LIST_URI + "?course_id=" + course_id.to_deprecated_string() @@ -177,7 +108,7 @@ class EmptyRoleTestCase(ApiTestCase): self.assertEqual(result["results"], []) -class UserApiTestCase(ApiTestCase): +class UserApiTestCase(UserAPITestCase): """ Generalized test case class for specific implementations below """ @@ -607,7 +538,7 @@ class PreferenceUsersListViewTest(UserApiTestCase): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class LoginSessionViewTest(ApiTestCase): +class LoginSessionViewTest(UserAPITestCase): """Tests for the login end-points of the user API. """ USERNAME = "bob" @@ -773,7 +704,7 @@ class LoginSessionViewTest(ApiTestCase): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class PasswordResetViewTest(ApiTestCase): +class PasswordResetViewTest(UserAPITestCase): """Tests of the user API's password reset endpoint. """ def setUp(self): @@ -829,7 +760,7 @@ class PasswordResetViewTest(ApiTestCase): @ddt.ddt @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase): +class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): """Tests for the registration end-points of the User API. """ maxDiff = None @@ -1925,7 +1856,7 @@ class TestGoogleRegistrationView( @ddt.ddt -class UpdateEmailOptInTestCase(ApiTestCase, SharedModuleStoreTestCase): +class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase): """Tests the UpdateEmailOptInPreference view. """ USERNAME = "steve" diff --git a/openedx/core/lib/api/test_utils.py b/openedx/core/lib/api/test_utils.py new file mode 100644 index 0000000000..9638a842a6 --- /dev/null +++ b/openedx/core/lib/api/test_utils.py @@ -0,0 +1,78 @@ +""" +Helpers for API tests. +""" +import base64 +import json + +import re +from django.test import TestCase +from django.test.utils import override_settings + +TEST_API_KEY = "test_api_key" + + +@override_settings(EDX_API_KEY=TEST_API_KEY) +class ApiTestCase(TestCase): + """ + Parent test case for API workflow coverage + """ + + def basic_auth(self, username, password): + """ + Returns a dictionary containing the http auth header with encoded username+password + """ + return {'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('%s:%s' % (username, password))} + + def request_with_auth(self, method, *args, **kwargs): + """Issue a get request to the given URI with the API key header""" + return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs) + + def get_json(self, *args, **kwargs): + """Make a request with the given args and return the parsed JSON repsonse""" + resp = self.request_with_auth("get", *args, **kwargs) + self.assertHttpOK(resp) + self.assertTrue(resp["Content-Type"].startswith("application/json")) + return json.loads(resp.content) + + def assertAllowedMethods(self, uri, expected_methods): + """Assert that the allowed methods for the given URI match the expected list""" + resp = self.request_with_auth("options", uri) + self.assertHttpOK(resp) + allow_header = resp.get("Allow") + self.assertIsNotNone(allow_header) + allowed_methods = re.split('[^A-Z]+', allow_header) + self.assertItemsEqual(allowed_methods, expected_methods) + + def assertSelfReferential(self, obj): + """Assert that accessing the "url" entry in the given object returns the same object""" + copy = self.get_json(obj["url"]) + self.assertEqual(obj, copy) + + def assertHttpOK(self, response): + """Assert that the given response has the status code 200""" + self.assertEqual(response.status_code, 200) + + def assertHttpForbidden(self, response): + """Assert that the given response has the status code 403""" + self.assertEqual(response.status_code, 403) + + def assertHttpBadRequest(self, response): + """Assert that the given response has the status code 400""" + self.assertEqual(response.status_code, 400) + + def assertHttpMethodNotAllowed(self, response): + """Assert that the given response has the status code 405""" + self.assertEqual(response.status_code, 405) + + def assertAuthDisabled(self, method, uri): + """ + Assert that the Django rest framework does not interpret basic auth + headers for views exposed to anonymous users as an attempt to authenticate. + + """ + # Django rest framework interprets basic auth headers + # as an attempt to authenticate with the API. + # We don't want this for views available to anonymous users. + basic_auth_header = "Basic " + base64.b64encode('username:password') + response = getattr(self.client, method)(uri, HTTP_AUTHORIZATION=basic_auth_header) + self.assertNotEqual(response.status_code, 403) From a04a635efc4af31ec3b9a3ec663eff93a8b7c961 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Thu, 3 Dec 2015 03:56:46 +0000 Subject: [PATCH 05/14] Add accomplishments to user profile --- .../collections/paging_collection.js | 42 ++--- .../static/common/js/components/views/list.js | 20 ++- .../js/components/views/paginated_view.js | 13 +- .../js/components/views/paging_footer.js | 4 +- .../js/components}/views/tabbed_view.js | 13 +- .../js/spec/components}/tabbed_view_spec.js | 5 +- .../static/common/js/spec/main_requirejs.js | 1 + .../common/js/spec_helpers/ajax_helpers.js | 4 + .../components/paging-footer.underscore | 8 +- .../templates/components}/tab.underscore | 0 .../components}/tabbed_view.underscore | 2 +- .../templates/components}/tabpanel.underscore | 0 lms/djangoapps/badges/api/serializers.py | 4 +- lms/djangoapps/badges/api/views.py | 18 +- .../badges/migrations/0001_initial.py | 8 +- .../0002_data__migrate_assertions.py | 12 +- .../0003_schema__add_event_configuration.py | 6 +- lms/djangoapps/badges/models.py | 15 +- lms/djangoapps/student_profile/views.py | 17 +- .../teams/static/teams/js/collections/team.js | 4 +- .../static/teams/js/collections/topic.js | 4 +- .../static/teams/js/teams_tab_factory.js | 3 +- .../teams/static/teams/js/views/teams.js | 2 + .../teams/js/views/teams_tabbed_view.js | 2 +- lms/djangoapps/teams/views.py | 30 +--- lms/envs/common.py | 3 + .../js/bookmarks/collections/bookmarks.js | 2 +- lms/static/js/edxnotes/collections/notes.js | 2 +- lms/static/js/spec/main.js | 1 - lms/static/js/spec/student_account/helpers.js | 7 +- lms/static/js/spec/student_profile/helpers.js | 117 ++++++++++++- .../learner_profile_factory_spec.js | 165 ++++++++++++++++++ .../learner_profile_view_spec.js | 21 ++- .../models/user_account_model.js | 1 + .../js/student_profile/models/badges_model.js | 8 + .../views/badge_list_container.js | 23 +++ .../student_profile/views/badge_list_view.js | 40 +++++ .../js/student_profile/views/badge_view.js | 23 +++ .../views/learner_profile_factory.js | 21 ++- .../views/learner_profile_view.js | 84 ++++++++- .../student_profile/views/section_two_tab.js | 30 ++++ lms/static/js_test.yml | 1 - lms/static/sass/_build-lms.scss | 1 + lms/static/sass/elements/_navigation.scss | 36 ++++ lms/static/sass/views/_learner-profile.scss | 86 ++++++++- lms/static/sass/views/_teams.scss | 35 +--- .../student_profile/badge.underscore | 16 ++ .../student_profile/badge_list.underscore | 4 + .../badge_placeholder.underscore | 10 ++ .../learner_profile.underscore | 15 +- .../student_profile/section_two.underscore | 10 ++ lms/urls.py | 2 +- .../core/djangoapps/user_api/accounts/api.py | 8 +- .../user_api/accounts/serializers.py | 88 ++++++---- .../user_api/accounts/tests/test_api.py | 3 +- .../user_api/accounts/tests/test_views.py | 17 +- .../djangoapps/user_api/accounts/views.py | 10 +- .../core/djangoapps/user_api/permissions.py | 35 ++++ openedx/core/lib/api/paginators.py | 3 + 59 files changed, 936 insertions(+), 229 deletions(-) rename {lms/static/js/components/tabbed => common/static/common/js/components}/views/tabbed_view.js (93%) rename {lms/static/js/spec/components/tabbed => common/static/common/js/spec/components}/tabbed_view_spec.js (97%) rename {lms/templates/components/tabbed => common/static/common/templates/components}/tab.underscore (100%) rename {lms/templates/components/tabbed => common/static/common/templates/components}/tabbed_view.underscore (50%) rename {lms/templates/components/tabbed => common/static/common/templates/components}/tabpanel.underscore (100%) create mode 100644 lms/static/js/student_profile/models/badges_model.js create mode 100644 lms/static/js/student_profile/views/badge_list_container.js create mode 100644 lms/static/js/student_profile/views/badge_list_view.js create mode 100644 lms/static/js/student_profile/views/badge_view.js create mode 100644 lms/static/js/student_profile/views/section_two_tab.js create mode 100644 lms/templates/student_profile/badge.underscore create mode 100644 lms/templates/student_profile/badge_list.underscore create mode 100644 lms/templates/student_profile/badge_placeholder.underscore create mode 100644 lms/templates/student_profile/section_two.underscore create mode 100644 openedx/core/djangoapps/user_api/permissions.py diff --git a/common/static/common/js/components/collections/paging_collection.js b/common/static/common/js/components/collections/paging_collection.js index 0259b56c2c..3acb1e033d 100644 --- a/common/static/common/js/components/collections/paging_collection.js +++ b/common/static/common/js/components/collections/paging_collection.js @@ -21,10 +21,32 @@ define(['backbone.paginator'], function (BackbonePaginator) { var PagingCollection = BackbonePaginator.requestPager.extend({ initialize: function () { + var self = this; // These must be initialized in the constructor because otherwise all PagingCollections would point // to the same object references for sortableFields and filterableFields. this.sortableFields = {}; this.filterableFields = {}; + + this.paginator_core = { + type: 'GET', + dataType: 'json', + url: function () { return this.url; } + }; + this.paginator_ui = { + firstPage: function () { return self.isZeroIndexed ? 0 : 1; }, + // Specifies the initial page during collection initialization + currentPage: self.isZeroIndexed ? 0 : 1, + perPage: function () { return self.perPage; } + }; + + this.currentPage = this.paginator_ui.currentPage; + + this.server_api = { + page: function () { return self.currentPage; }, + page_size: function () { return self.perPage; }, + text_search: function () { return self.searchString ? self.searchString : ''; }, + sort_order: function () { return self.sortField; } + }; }, isZeroIndexed: false, @@ -41,26 +63,6 @@ searchString: null, - paginator_core: { - type: 'GET', - dataType: 'json', - url: function () { return this.url; } - }, - - paginator_ui: { - firstPage: function () { return this.isZeroIndexed ? 0 : 1; }, - // Specifies the initial page during collection initialization - currentPage: function () { return this.isZeroIndexed ? 0 : 1; }, - perPage: function () { return this.perPage; } - }, - - server_api: { - page: function () { return this.currentPage; }, - page_size: function () { return this.perPage; }, - text_search: function () { return this.searchString ? this.searchString : ''; }, - sort_order: function () { return this.sortField; } - }, - parse: function (response) { this.totalCount = response.count; this.currentPage = response.current_page; diff --git a/common/static/common/js/components/views/list.js b/common/static/common/js/components/views/list.js index b8c319afaa..ed3a64f4af 100644 --- a/common/static/common/js/components/views/list.js +++ b/common/static/common/js/components/views/list.js @@ -24,18 +24,26 @@ this.itemViews = []; }, + renderCollection: function() { + /** + * Render every item in the collection. + * This should push each rendered item to this.itemViews + * to ensure garbage collection works. + */ + this.collection.each(function (model) { + var itemView = new this.itemViewClass({model: model}); + this.$el.append(itemView.render().el); + this.itemViews.push(itemView); + }, this); + }, + render: function () { // Remove old children views _.each(this.itemViews, function (childView) { childView.remove(); }); this.itemViews = []; - // Render the collection - this.collection.each(function (model) { - var itemView = new this.itemViewClass({model: model}); - this.$el.append(itemView.render().el); - this.itemViews.push(itemView); - }, this); + this.renderCollection(); return this; } }); diff --git a/common/static/common/js/components/views/paginated_view.js b/common/static/common/js/components/views/paginated_view.js index 7755a93f1d..4bf085d227 100644 --- a/common/static/common/js/components/views/paginated_view.js +++ b/common/static/common/js/components/views/paginated_view.js @@ -26,7 +26,7 @@ ], function (Backbone, _, PagingHeader, PagingFooter, ListView, paginatedViewTemplate) { var PaginatedView = Backbone.View.extend({ initialize: function () { - var ItemListView = ListView.extend({ + var ItemListView = this.listViewClass.extend({ tagName: 'div', className: this.type + '-container', itemViewClass: this.itemViewClass @@ -39,18 +39,25 @@ }, this); }, + listViewClass: ListView, + + viewTemplate: paginatedViewTemplate, + + paginationLabel: gettext("Pagination"), + createHeaderView: function() { return new PagingHeader({collection: this.options.collection, srInfo: this.srInfo}); }, createFooterView: function() { return new PagingFooter({ - collection: this.options.collection, hideWhenOnePage: true + collection: this.options.collection, hideWhenOnePage: true, + paginationLabel: this.paginationLabel }); }, render: function () { - this.$el.html(_.template(paginatedViewTemplate)({type: this.type})); + this.$el.html(_.template(this.viewTemplate)({type: this.type})); this.assign(this.listView, '.' + this.type + '-list'); if (this.headerView) { this.assign(this.headerView, '.' + this.type + '-paging-header'); diff --git a/common/static/common/js/components/views/paging_footer.js b/common/static/common/js/components/views/paging_footer.js index 8b796b52b9..b68a0e9133 100644 --- a/common/static/common/js/components/views/paging_footer.js +++ b/common/static/common/js/components/views/paging_footer.js @@ -13,6 +13,7 @@ initialize: function(options) { this.collection = options.collection; this.hideWhenOnePage = options.hideWhenOnePage || false; + this.paginationLabel = options.paginationLabel || gettext("Pagination"); this.collection.bind('add', _.bind(this.render, this)); this.collection.bind('remove', _.bind(this.render, this)); this.collection.bind('reset', _.bind(this.render, this)); @@ -32,7 +33,8 @@ } this.$el.html(_.template(paging_footer_template)({ current_page: this.collection.getPage(), - total_pages: this.collection.totalPages + total_pages: this.collection.totalPages, + paginationLabel: this.paginationLabel })); this.$(".previous-page-link").toggleClass("is-disabled", onFirstPage).attr('aria-disabled', onFirstPage); this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage); diff --git a/lms/static/js/components/tabbed/views/tabbed_view.js b/common/static/common/js/components/views/tabbed_view.js similarity index 93% rename from lms/static/js/components/tabbed/views/tabbed_view.js rename to common/static/common/js/components/views/tabbed_view.js index 367a803ef3..375d9f36f4 100644 --- a/lms/static/js/components/tabbed/views/tabbed_view.js +++ b/common/static/common/js/components/views/tabbed_view.js @@ -3,9 +3,9 @@ define(['backbone', 'underscore', 'jquery', - 'text!templates/components/tabbed/tabbed_view.underscore', - 'text!templates/components/tabbed/tab.underscore', - 'text!templates/components/tabbed/tabpanel.underscore', + 'text!common/templates/components/tabbed_view.underscore', + 'text!common/templates/components/tab.underscore', + 'text!common/templates/components/tabpanel.underscore', ], function ( Backbone, _, @@ -37,8 +37,6 @@ 'click .nav-item.tab': 'switchTab' }, - template: _.template(tabbedViewTemplate), - /** * View for a tabbed interface. Expects a list of tabs * in its options object, each of which should contain the @@ -51,12 +49,13 @@ * If a router is passed in (via options.router), * use that router to keep track of history between * tabs. Backbone.history.start() must be called - * by the router's instatiator after this view is + * by the router's instantiator after this view is * initialized. */ initialize: function (options) { this.router = options.router || null; this.tabs = options.tabs; + this.template = _.template(tabbedViewTemplate)({viewLabel: options.viewLabel}); // Convert each view into a TabPanelView _.each(this.tabs, function (tabInfo) { tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view}); @@ -69,7 +68,7 @@ render: function () { var self = this; - this.$el.html(this.template({})); + this.$el.html(this.template); _.each(this.tabs, function(tabInfo, index) { var tabEl = $(_.template(tabTemplate)({ index: index, diff --git a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js b/common/static/common/js/spec/components/tabbed_view_spec.js similarity index 97% rename from lms/static/js/spec/components/tabbed/tabbed_view_spec.js rename to common/static/common/js/spec/components/tabbed_view_spec.js index cebe44f6d6..edf9319d0d 100644 --- a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js +++ b/common/static/common/js/spec/components/tabbed_view_spec.js @@ -4,7 +4,7 @@ define(['jquery', 'underscore', 'backbone', - 'js/components/tabbed/views/tabbed_view' + 'common/js/components/views/tabbed_view' ], function($, _, Backbone, TabbedView) { var view, @@ -36,7 +36,8 @@ title: 'Test 2', view: new TestSubview({text: 'other text'}), url: 'test-2' - }] + }], + viewLabel: 'Tabs', }).render(); // _.defer() is used to make calls to diff --git a/common/static/common/js/spec/main_requirejs.js b/common/static/common/js/spec/main_requirejs.js index 23d8adfcb1..94a40ca2de 100644 --- a/common/static/common/js/spec/main_requirejs.js +++ b/common/static/common/js/spec/main_requirejs.js @@ -155,6 +155,7 @@ define([ // Run the common tests that use RequireJS. + 'common-requirejs/include/common/js/spec/components/tabbed_view_spec.js', 'common-requirejs/include/common/js/spec/components/feedback_spec.js', 'common-requirejs/include/common/js/spec/components/list_spec.js', 'common-requirejs/include/common/js/spec/components/paginated_view_spec.js', diff --git a/common/static/common/js/spec_helpers/ajax_helpers.js b/common/static/common/js/spec_helpers/ajax_helpers.js index 665da718fe..5cf3f0cb1b 100644 --- a/common/static/common/js/spec_helpers/ajax_helpers.js +++ b/common/static/common/js/spec_helpers/ajax_helpers.js @@ -72,6 +72,10 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { expect(request.readyState).toEqual(XML_HTTP_READY_STATES.OPENED); 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. + return; + } expect(request.requestBody).toEqual(body); }; diff --git a/common/static/common/templates/components/paging-footer.underscore b/common/static/common/templates/components/paging-footer.underscore index d04bbc0d2d..28c34b6178 100644 --- a/common/static/common/templates/components/paging-footer.underscore +++ b/common/static/common/templates/components/paging-footer.underscore @@ -1,8 +1,12 @@ -