diff --git a/.gitignore b/.gitignore index 7f2a9d6777..015f3b52fc 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ jscover.log.* common/test/data/test_unicode/static/ django-pyfs test_root/uploads/*.txt +test_root/uploads/badges/*.png ### Installation artifacts *.egg-info diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 388b74d98f..5d64e0a540 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -684,7 +684,14 @@ class CourseFields(object): # Ensure that courses imported from XML keep their image default="images_course_image.jpg" ) - + issue_badges = Boolean( + display_name=_("Issue Open Badges"), + help=_( + "Issue Open Badges badges for this course. Badges are generated when certificates are created." + ), + scope=Scope.settings, + default=True + ) ## Course level Certificate Name overrides. cert_name_short = String( help=_( diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index dfca78bd55..9a4bf0a82a 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -162,6 +162,7 @@ class AdvancedSettingsPage(CoursePage): 'info_sidebar_name', 'is_new', 'ispublic', + 'issue_badges', 'max_student_enrollments_allowed', 'no_grade', 'display_coursenumber', diff --git a/common/test/data/badges/good.png b/common/test/data/badges/good.png new file mode 100644 index 0000000000..eff1fb0bc4 Binary files /dev/null and b/common/test/data/badges/good.png differ diff --git a/common/test/data/badges/large.png b/common/test/data/badges/large.png new file mode 100644 index 0000000000..7b2ef38534 Binary files /dev/null and b/common/test/data/badges/large.png differ diff --git a/common/test/data/badges/unbalanced.png b/common/test/data/badges/unbalanced.png new file mode 100644 index 0000000000..1c8244cb31 Binary files /dev/null and b/common/test/data/badges/unbalanced.png differ diff --git a/lms/djangoapps/certificates/admin.py b/lms/djangoapps/certificates/admin.py index 783e621362..eb1c29a7f9 100644 --- a/lms/djangoapps/certificates/admin.py +++ b/lms/djangoapps/certificates/admin.py @@ -3,8 +3,11 @@ django admin pages for certificates models """ from django.contrib import admin from config_models.admin import ConfigurationModelAdmin -from certificates.models import CertificateGenerationConfiguration, CertificateHtmlViewConfiguration +from certificates.models import ( + CertificateGenerationConfiguration, CertificateHtmlViewConfiguration, BadgeImageConfiguration +) admin.site.register(CertificateGenerationConfiguration) admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin) +admin.site.register(BadgeImageConfiguration) diff --git a/lms/djangoapps/certificates/badge_handler.py b/lms/djangoapps/certificates/badge_handler.py new file mode 100644 index 0000000000..31eaf4ec27 --- /dev/null +++ b/lms/djangoapps/certificates/badge_handler.py @@ -0,0 +1,195 @@ +""" +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( + start_date=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.badges.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( + 'cert_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 b3cd1dd8f8..cacc23d512 100644 --- a/lms/djangoapps/certificates/management/commands/regenerate_user.py +++ b/lms/djangoapps/certificates/management/commands/regenerate_user.py @@ -10,6 +10,7 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from xmodule.modulestore.django import modulestore from certificates.queue import XQueueCertInterface +from certificates.models import BadgeAssertion LOGGER = logging.getLogger(__name__) @@ -116,6 +117,13 @@ class Command(BaseCommand): template_file=options['template_file'] ) + 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/0021_auto__add_badgeassertion__add_unique_badgeassertion_course_id_user__ad.py b/lms/djangoapps/certificates/migrations/0021_auto__add_badgeassertion__add_unique_badgeassertion_course_id_user__ad.py new file mode 100644 index 0000000000..90dd7a0cba --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0021_auto__add_badgeassertion__add_unique_badgeassertion_course_id_user__ad.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'BadgeAssertion' + db.create_table('certificates_badgeassertion', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(default=None, max_length=255, blank=True)), + ('mode', self.gf('django.db.models.fields.CharField')(max_length=100)), + ('data', self.gf('django.db.models.fields.TextField')(default='{}')), + )) + db.send_create_signal('certificates', ['BadgeAssertion']) + + # Adding unique constraint on 'BadgeAssertion', fields ['course_id', 'user'] + db.create_unique('certificates_badgeassertion', ['course_id', 'user_id']) + + # Adding model 'BadgeImageConfiguration' + db.create_table('certificates_badgeimageconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('mode', self.gf('django.db.models.fields.CharField')(unique=True, max_length=125)), + ('icon', self.gf('django.db.models.fields.files.ImageField')(max_length=100)), + ('default', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('certificates', ['BadgeImageConfiguration']) + + + def backwards(self, orm): + # Removing unique constraint on 'BadgeAssertion', fields ['course_id', 'user'] + db.delete_unique('certificates_badgeassertion', ['course_id', 'user_id']) + + # Deleting model 'BadgeAssertion' + db.delete_table('certificates_badgeassertion') + + # Deleting model 'BadgeImageConfiguration' + db.delete_table('certificates_badgeimageconfiguration') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.badgeassertion': { + 'Meta': {'unique_together': "(('course_id', 'user'),)", 'object_name': 'BadgeAssertion'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'certificates.badgeimageconfiguration': { + 'Meta': {'object_name': 'BadgeImageConfiguration'}, + 'default': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '125'}) + }, + 'certificates.certificategenerationconfiguration': { + 'Meta': {'object_name': 'CertificateGenerationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificategenerationcoursesetting': { + 'Meta': {'object_name': 'CertificateGenerationCourseSetting'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.certificatehtmlviewconfiguration': { + 'Meta': {'object_name': 'CertificateHtmlViewConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'configuration': ('django.db.models.fields.TextField', [], {}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.examplecertificate': { + 'Meta': {'object_name': 'ExampleCertificate'}, + 'access_key': ('django.db.models.fields.CharField', [], {'default': "'fa917079f0aa4f969e92bd8722d082c6'", 'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + 'error_reason': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + 'example_cert_set': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['certificates.ExampleCertificateSet']"}), + 'full_name': ('django.db.models.fields.CharField', [], {'default': "u'John Do\\xeb'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'started'", 'max_length': '255'}), + 'template': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'uuid': ('django.db.models.fields.CharField', [], {'default': "'ac948bae296c4d54a87bdd3e6c177adf'", 'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'certificates.examplecertificateset': { + 'Meta': {'object_name': 'ExampleCertificateSet'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['certificates'] \ No newline at end of file diff --git a/lms/djangoapps/certificates/migrations/0022_default_modes.py b/lms/djangoapps/certificates/migrations/0022_default_modes.py new file mode 100644 index 0000000000..0335aff6c9 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0022_default_modes.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models +from django.core.files import File +from django.conf import settings + + +class Migration(DataMigration): + + def forwards(self, orm): + """Add default modes""" + for mode in ['honor', 'verified', 'professional']: + conf = orm.BadgeImageConfiguration() + conf.mode = mode + file_name = mode + '.png' + conf.icon.save( + file_name, + File(open(settings.PROJECT_ROOT / 'static' / 'images' / 'default-badges' / file_name)) + ) + conf.save() + + def backwards(self, orm): + """Do nothing, assumptions too dangerous.""" + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.badgeassertion': { + 'Meta': {'unique_together': "(('course_id', 'user'),)", 'object_name': 'BadgeAssertion'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'certificates.badgeimageconfiguration': { + 'Meta': {'object_name': 'BadgeImageConfiguration'}, + 'default': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '125'}) + }, + 'certificates.certificategenerationconfiguration': { + 'Meta': {'object_name': 'CertificateGenerationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificategenerationcoursesetting': { + 'Meta': {'object_name': 'CertificateGenerationCourseSetting'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.certificatehtmlviewconfiguration': { + 'Meta': {'object_name': 'CertificateHtmlViewConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'configuration': ('django.db.models.fields.TextField', [], {}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.examplecertificate': { + 'Meta': {'object_name': 'ExampleCertificate'}, + 'access_key': ('django.db.models.fields.CharField', [], {'default': "'6712301c558d4f41a0491bb12c9ab688'", 'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + 'error_reason': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + 'example_cert_set': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['certificates.ExampleCertificateSet']"}), + 'full_name': ('django.db.models.fields.CharField', [], {'default': "u'John Do\\xeb'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'started'", 'max_length': '255'}), + 'template': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'uuid': ('django.db.models.fields.CharField', [], {'default': "'86d042630fdf4efcb8e705baad30c89f'", 'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'certificates.examplecertificateset': { + 'Meta': {'object_name': 'ExampleCertificateSet'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['certificates'] + symmetrical = True diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index d994d249d5..2eb48d68a4 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -47,23 +47,27 @@ Eligibility: """ from datetime import datetime import json +import logging import uuid -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.signals import post_save from django.dispatch import receiver from django.conf import settings -from django.utils.translation import ugettext_lazy +from django.utils.translation import ugettext_lazy as _ +from django_extensions.db.fields.json import JSONField from model_utils import Choices from model_utils.models import TimeStampedModel +from xmodule.modulestore.django import modulestore from config_models.models import ConfigurationModel from xmodule_django.models import CourseKeyField, NoneToEmptyManager from util.milestones_helpers import fulfill_course_milestone from course_modes.models import CourseMode +LOGGER = logging.getLogger(__name__) + class CertificateStatuses(object): deleted = 'deleted' @@ -331,7 +335,7 @@ class ExampleCertificate(TimeStampedModel): description = models.CharField( max_length=255, - help_text=ugettext_lazy( + help_text=_( u"A human-readable description of the example certificate. " u"For example, 'verified' or 'honor' to differentiate between " u"two types of certificates." @@ -346,7 +350,7 @@ class ExampleCertificate(TimeStampedModel): default=_make_uuid, db_index=True, unique=True, - help_text=ugettext_lazy( + help_text=_( u"A unique identifier for the example certificate. " u"This is used when we receive a response from the queue " u"to determine which example certificate was processed." @@ -357,7 +361,7 @@ class ExampleCertificate(TimeStampedModel): max_length=255, default=_make_uuid, db_index=True, - help_text=ugettext_lazy( + help_text=_( u"An access key for the example certificate. " u"This is used when we receive a response from the queue " u"to validate that the sender is the same entity we asked " @@ -368,12 +372,12 @@ class ExampleCertificate(TimeStampedModel): full_name = models.CharField( max_length=255, default=EXAMPLE_FULL_NAME, - help_text=ugettext_lazy(u"The full name that will appear on the certificate.") + help_text=_(u"The full name that will appear on the certificate.") ) template = models.CharField( max_length=255, - help_text=ugettext_lazy(u"The template file to use when generating the certificate.") + help_text=_(u"The template file to use when generating the certificate.") ) # Outputs from certificate generation @@ -385,20 +389,20 @@ class ExampleCertificate(TimeStampedModel): (STATUS_SUCCESS, 'Success'), (STATUS_ERROR, 'Error') ), - help_text=ugettext_lazy(u"The status of the example certificate.") + help_text=_(u"The status of the example certificate.") ) error_reason = models.TextField( null=True, default=None, - help_text=ugettext_lazy(u"The reason an error occurred during certificate generation.") + help_text=_(u"The reason an error occurred during certificate generation.") ) download_url = models.CharField( max_length=255, null=True, default=None, - help_text=ugettext_lazy(u"The download URL for the generated certificate.") + help_text=_(u"The download URL for the generated certificate.") ) def update_status(self, status, error_reason=None, download_url=None): @@ -574,3 +578,113 @@ class CertificateHtmlViewConfiguration(ConfigurationModel): instance = cls.current() json_data = json.loads(instance.configuration) if instance.enabled else {} 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): + """ + Meta information for Django's construction of the model. + """ + unique_together = (('course_id', 'user', 'mode'),) + + +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 + """ + 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( + 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. + """ + # pylint: disable=no-member + 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 + + +@receiver(post_save, sender=GeneratedCertificate) +#pylint: disable=unused-argument +def create_badge(sender, instance, **kwargs): + """ + Standard signal hook to create badges when a certificate has been generated. + """ + if not settings.FEATURES.get('ENABLE_OPENBADGES', False): + return + if not modulestore().get_course(instance.course_id).issue_badges: + LOGGER.info("Course is not configured to issue badges.") + return + if BadgeAssertion.objects.filter(user=instance.user, course_id=instance.course_id): + LOGGER.info("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 instance.status == CertificateStatuses.downloadable: + return + from .badge_handler import BadgeHandler + handler = BadgeHandler(instance.course_id) + handler.award(instance.user) diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index 4b16719736..b22fe0c8eb 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -1,14 +1,15 @@ -from factory.django import DjangoModelFactory +# Factories are self documenting +# pylint: disable=missing-docstring +from factory.django import DjangoModelFactory, ImageField from student.models import LinkedInAddToProfileConfiguration from certificates.models import ( - GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist + GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion, + BadgeImageConfiguration, ) -# Factories are self documenting -# pylint: disable=missing-docstring class GeneratedCertificateFactory(DjangoModelFactory): FACTORY_FOR = GeneratedCertificate @@ -27,6 +28,20 @@ class CertificateWhitelistFactory(DjangoModelFactory): whitelist = True +class BadgeAssertionFactory(DjangoModelFactory): + FACTORY_FOR = BadgeAssertion + + mode = 'honor' + + +class BadgeImageConfigurationFactory(DjangoModelFactory): + + FACTORY_FOR = BadgeImageConfiguration + + mode = 'honor' + icon = ImageField(color='blue', height=50, width=50, filename='test.png', format='PNG') + + class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): FACTORY_FOR = CertificateHtmlViewConfiguration diff --git a/lms/djangoapps/certificates/tests/test_badge_handler.py b/lms/djangoapps/certificates/tests/test_badge_handler.py new file mode 100644 index 0000000000..787bfb039f --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_badge_handler.py @@ -0,0 +1,196 @@ +""" +Tests for the BadgeHandler, which communicates with the Badgr Server. +""" +from datetime import datetime +from django.test.utils import override_settings +from django.db.models.fields.files import ImageFieldFile +from lazy.lazy import lazy +from mock import patch, Mock, call +from certificates.models import BadgeAssertion, BadgeImageConfiguration +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 xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +BADGR_SETTINGS = { + 'BADGR_API_TOKEN': '12345', + 'BADGR_BASE_URL': 'https://example.com', + 'BADGR_ISSUER_SLUG': 'test-issuer', +} + + +@override_settings(**BADGR_SETTINGS) +class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase): + """ + Tests the BadgeHandler object + """ + def setUp(self): + """ + Create a course and user to test with. + """ + super(BadgeHandlerTestCase, 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.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() + + @lazy + def handler(self): + """ + Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings + overrides aren't in place. + """ + return BadgeHandler(self.course.location.course_key) + + 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.badge_url('honor'), + 'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b' + ) + self.assertEqual( + self.handler.assertion_url('honor'), + 'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b/assertions' + ) + + def check_headers(self, headers): + """ + Verify the a headers dict from a requests call matches the proper auth info. + """ + 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()) + + @patch('requests.post') + def test_create_badge(self, post): + """ + Verify badge spec creation works. + """ + self.handler.create_badge('honor') + 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.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)', + } + ) + + 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) + + @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.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' + ) + self.check_headers(kwargs['headers']) + self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b']) + 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']) + + @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', + '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') + args, kwargs = post.call_args + self.assertEqual( + args[0], + 'https://example.com/v1/issuer/issuers/test-issuer/badges/' + 'edxcourse_testtest_run_honor_fc5519b/assertions' + ) + self.check_headers(kwargs['headers']) + assertion = BadgeAssertion.objects.get(user=self.user, 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(kwargs['data'], { + 'email': 'example@example.com', + 'evidence': 'https://edx.org/certificates/user/2/course/edX/course_test/test_run?evidence_visit=1' + }) + assert_event_matches({ + 'name': 'edx.badges.assertion.created', + 'data': { + 'user_id': self.user.id, + 'course_id': unicode(self.course.location.course_key), + 'enrollment_mode': 'honor', + 'assertion_id': assertion.id, + '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', + } + }, self.get_event()) diff --git a/lms/djangoapps/certificates/tests/test_resubmit_error_certificates.py b/lms/djangoapps/certificates/tests/test_cert_management.py similarity index 72% rename from lms/djangoapps/certificates/tests/test_resubmit_error_certificates.py rename to lms/djangoapps/certificates/tests/test_cert_management.py index d4e435cdbb..0e9d7537db 100644 --- a/lms/djangoapps/certificates/tests/test_resubmit_error_certificates.py +++ b/lms/djangoapps/certificates/tests/test_cert_management.py @@ -2,28 +2,64 @@ import ddt from django.core.management.base import CommandError from nose.plugins.attrib import attr +from django.test.utils import override_settings +from mock import patch from opaque_keys.edx.locator import CourseLocator +from certificates.tests.factories import BadgeAssertionFactory 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 -from certificates.models import GeneratedCertificate, CertificateStatuses +from certificates.management.commands import resubmit_error_certificates, regenerate_user +from certificates.models import GeneratedCertificate, CertificateStatuses, BadgeAssertion -@attr('shard_1') -@ddt.ddt -class ResubmitErrorCertificatesTest(ModuleStoreTestCase): - """Tests for the resubmit_error_certificates management command. """ +class CertificateManagementTest(ModuleStoreTestCase): + """ + Base test class for Certificate Management command tests. + """ + # Override with the command module you wish to test. + command = resubmit_error_certificates def setUp(self): - super(ResubmitErrorCertificatesTest, self).setUp() + super(CertificateManagementTest, self).setUp() self.user = UserFactory.create() self.courses = [ CourseFactory.create() for __ in range(3) ] + def _create_cert(self, course_key, user, status): + """Create a certificate entry. """ + # Enroll the user in the course + CourseEnrollmentFactory.create( + user=user, + course_id=course_key + ) + + # Create the certificate + GeneratedCertificate.objects.create( + user=user, + course_id=course_key, + status=status + ) + + def _run_command(self, *args, **kwargs): + """Run the management command to generate a fake cert. """ + command = self.command.Command() + return command.handle(*args, **kwargs) + + def _assert_cert_status(self, course_key, user, expected_status): + """Check the status of a certificate. """ + cert = GeneratedCertificate.objects.get(user=user, course_id=course_key) + self.assertEqual(cert.status, expected_status) + + +@attr('shard_1') +@ddt.ddt +class ResubmitErrorCertificatesTest(CertificateManagementTest): + """Tests for the resubmit_error_certificates management command. """ + def test_resubmit_error_certificate(self): # Create a certificate with status 'error' self._create_cert(self.courses[0].id, self.user, CertificateStatuses.error) @@ -105,27 +141,39 @@ class ResubmitErrorCertificatesTest(ModuleStoreTestCase): # since the course doesn't actually exist. self._assert_cert_status(phantom_course, self.user, CertificateStatuses.error) - def _create_cert(self, course_key, user, status): - """Create a certificate entry. """ - # Enroll the user in the course - CourseEnrollmentFactory.create( - user=user, - course_id=course_key + +@attr('shard_1') +class RegenerateCertificatesTest(CertificateManagementTest): + """ + Tests for regenerating certificates. + """ + command = regenerate_user + + def setUp(self): + """ + We just need one course here. + """ + super(RegenerateCertificatesTest, self).setUp() + self.course = self.courses[0] + + @override_settings(CERT_QUEUE='test-queue') + @patch('certificates.management.commands.regenerate_user.XQueueCertInterface', spec=True) + def test_clear_badge(self, xqueue): + """ + Given that I have a user with a badge + If I run regeneration for a user + Then certificate generation will be requested + 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)) + self._run_command( + username=self.user.email, course=unicode(key), noop=False, insecure=False, template_file=None, + grade_value=None ) - - # Create the certificate - GeneratedCertificate.objects.create( - user=user, - course_id=course_key, - status=status + xqueue.return_value.regen_cert.assert_called_with( + self.user, key, course=self.course, forced_grade=None, template_file=None ) - - def _run_command(self, *args, **kwargs): - """Run the management command to generate a fake cert. """ - command = resubmit_error_certificates.Command() - return command.handle(*args, **kwargs) - - def _assert_cert_status(self, course_key, user, expected_status): - """Check the status of a certificate. """ - cert = GeneratedCertificate.objects.get(user=user, course_id=course_key) - self.assertEqual(cert.status, expected_status) + self.assertFalse(BadgeAssertion.objects.filter(user=self.user, course_id=key)) diff --git a/lms/djangoapps/certificates/tests/test_models.py b/lms/djangoapps/certificates/tests/test_models.py index 2829806a92..755f2b233c 100644 --- a/lms/djangoapps/certificates/tests/test_models.py +++ b/lms/djangoapps/certificates/tests/test_models.py @@ -1,20 +1,29 @@ """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.test import TestCase from django.test.utils import override_settings from nose.plugins.attrib import attr +# pylint: disable=no-name-in-module +from path import path from opaque_keys.edx.locator import CourseLocator from certificates.models import ( ExampleCertificate, ExampleCertificateSet, - CertificateHtmlViewConfiguration -) + CertificateHtmlViewConfiguration, + BadgeImageConfiguration) FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy() FEATURES_INVALID_FILE_PATH['CERTS_HTML_VIEW_CONFIG_PATH'] = 'invalid/path/to/config.json' +# pylint: disable=invalid-name +TEST_DIR = path(__file__).dirname() +TEST_DATA_DIR = 'common/test/data/' +PLATFORM_ROOT = TEST_DIR.parent.parent.parent.parent +TEST_DATA_ROOT = PLATFORM_ROOT / TEST_DATA_DIR + @attr('shard_1') class ExampleCertificateTest(TestCase): @@ -151,3 +160,52 @@ class CertificateHtmlViewConfigurationTest(TestCase): self.config.configuration = '' self.config.save() 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 + ) diff --git a/lms/djangoapps/certificates/tests/test_views.py b/lms/djangoapps/certificates/tests/test_views.py index c9138490df..7e7e55c822 100644 --- a/lms/djangoapps/certificates/tests/test_views.py +++ b/lms/djangoapps/certificates/tests/test_views.py @@ -13,16 +13,20 @@ from django.test.client import Client from django.test.utils import override_settings 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 certificates.api import get_certificate_url -from certificates.models import ExampleCertificateSet, ExampleCertificate, GeneratedCertificate +from certificates.models import ExampleCertificateSet, ExampleCertificate, GeneratedCertificate, BadgeAssertion from certificates.tests.factories import ( CertificateHtmlViewConfigurationFactory, - LinkedInAddToProfileConfigurationFactory + LinkedInAddToProfileConfigurationFactory, + BadgeAssertionFactory, ) +from lms import urls FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True @@ -174,7 +178,7 @@ class UpdateExampleCertificateViewTest(TestCase): @attr('shard_1') -class CertificatesViewsTests(ModuleStoreTestCase): +class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): """ Tests for the manual refund page """ @@ -416,3 +420,88 @@ class CertificatesViewsTests(ModuleStoreTestCase): ) response = self.client.get(test_url) self.assertIn("Invalid Certificate", response.content) + + def test_evidence_event_sent(self): + test_url = get_certificate_url(user_id=self.user.id, course_id=self.course_id) + '?evidence_visit=1' + self.recreate_tracker() + assertion = BadgeAssertion( + user=self.user, course_id=self.course_id, 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', + + } + ) + assertion.save() + response = self.client.get(test_url) + self.assertEqual(response.status_code, 200) + assert_event_matches( + { + 'name': 'edx.badges.assertion.evidence_visit', + 'data': { + 'course_id': 'testorg/run1/refundable_course', + # pylint: disable=no-member + 'assertion_id': 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() + ) + + +class TrackShareRedirectTest(ModuleStoreTestCase, EventTrackingTestCase): + """ + Verifies the badge image share event is sent out. + """ + def setUp(self): + super(TrackShareRedirectTest, self).setUp() + 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', + }, + ) + # Enabling the feature flag isn't enough to change the URLs-- they're already loaded by this point. + self.old_patterns = urls.urlpatterns + urls.urlpatterns += (urls.BADGE_SHARE_TRACKER_URL,) + + def tearDown(self): + super(TrackShareRedirectTest, self).tearDown() + urls.urlpatterns = self.old_patterns + + 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.badges.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/tests.py b/lms/djangoapps/certificates/tests/tests.py index e5e5a9980c..a3e06855b2 100644 --- a/lms/djangoapps/certificates/tests/tests.py +++ b/lms/djangoapps/certificates/tests/tests.py @@ -82,3 +82,20 @@ class CertificatesModelTest(ModuleStoreTestCase): completed_milestones = milestones_achieved_by_user(student, unicode(pre_requisite_course.id)) self.assertEqual(len(completed_milestones), 1) 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) + def test_badge_callback(self, handler): + student = UserFactory() + course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True) + 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/views.py b/lms/djangoapps/certificates/views.py index aad386de59..31234570eb 100644 --- a/lms/djangoapps/certificates/views.py +++ b/lms/djangoapps/certificates/views.py @@ -1,12 +1,14 @@ """URL handlers related to certificate handling by LMS""" from datetime import datetime from uuid import uuid4 +from django.shortcuts import redirect, get_object_or_404 +from opaque_keys.edx.locator import CourseLocator +from eventtracking import tracker import dogstats_wrapper as dog_stats_api import json import logging from django.conf import settings -from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.http import HttpResponse, Http404, HttpResponseForbidden from django.utils.translation import ugettext as _ @@ -20,10 +22,11 @@ from certificates.models import ( CertificateStatuses, GeneratedCertificate, ExampleCertificate, - CertificateHtmlViewConfiguration -) + CertificateHtmlViewConfiguration, + BadgeAssertion) from certificates.queue import XQueueCertInterface from edxmako.shortcuts import render_to_response +from util.views import ensure_valid_course_key from xmodule.modulestore.django import modulestore from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -293,6 +296,11 @@ def _update_certificate_context(context, course, user, user_certificate): context['accomplishment_copy_course_org'] = course.org context['accomplishment_copy_course_name'] = course.display_name context['logo_alt'] = platform_name + try: + badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key) + except BadgeAssertion.DoesNotExist: + badge = None + context['badge'] = badge # Override the defaults with any mode-specific static values context['certificate_id_number'] = user_certificate.verify_uuid @@ -513,6 +521,29 @@ def render_html_view(request, user_id, course_id): except (InvalidKeyError, CourseDoesNotExist, User.DoesNotExist): return render_to_response(invalid_template_path, context) + if 'evidence_visit' in request.GET: + print "Event request found!" + try: + badge = BadgeAssertion.objects.get(user=user, course_id=course_key) + tracker.emit( + 'edx.badges.assertion.evidence_visit', + { + 'user_id': user.id, + 'course_id': unicode(course_key), + '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'], + } + ) + except BadgeAssertion.DoesNotExist: + logger.warn( + "Could not find badge for %s on course %s.", + user.id, + course_key, + ) + # Okay, now we have all of the pieces, time to put everything together # Get the active certificate configuration for this course @@ -536,3 +567,25 @@ def render_html_view(request, user_id, course_id): context.update(course.cert_html_view_overrides) return render_to_response("certificates/valid.html", context) + + +@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.badges.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/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index 0c75863408..5adba1a528 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -12,6 +12,7 @@ from nose.plugins.attrib import attr from opaque_keys.edx.locations import SlashSeparatedCourseKey from course_modes.models import CourseMode +from track.tests import EventTrackingTestCase from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE from student.models import CourseEnrollment @@ -34,7 +35,7 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission @attr('shard_1') -class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): +class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingTestCase): """ Tests about xblock. """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 111d9a4c95..4928a20d4a 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -262,6 +262,11 @@ ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL") FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL") MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) +# Badgr API +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) + # git repo loading environment GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos') GIT_IMPORT_STATIC = ENV_TOKENS.get('GIT_IMPORT_STATIC', True) diff --git a/lms/envs/common.py b/lms/envs/common.py index 149f26382a..9da5231771 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -399,6 +399,9 @@ FEATURES = { # How many seconds to show the bumper again, default is 7 days: 'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600, + # Enable OpenBadge support. See the BADGR_* settings later in this file. + 'ENABLE_OPENBADGES': False, + } # Ignore static asset files on import which match this pattern @@ -2024,6 +2027,13 @@ REGISTRATION_EXTRA_FIELDS = { CERT_NAME_SHORT = "Certificate" CERT_NAME_LONG = "Certificate of Achievement" +#################### Badgr OpenBadges generation ####################### +# 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" + ###################### Grade Downloads ###################### GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE diff --git a/lms/static/certificates/images/backpack-logo.png b/lms/static/certificates/images/backpack-logo.png new file mode 100644 index 0000000000..a6963b084e Binary files /dev/null and b/lms/static/certificates/images/backpack-logo.png differ diff --git a/lms/static/certificates/images/backpack-ui.png b/lms/static/certificates/images/backpack-ui.png new file mode 100644 index 0000000000..9040c5b9f1 Binary files /dev/null and b/lms/static/certificates/images/backpack-ui.png differ diff --git a/lms/static/certificates/sass/_components.scss b/lms/static/certificates/sass/_components.scss index 976c6f6bb8..bda5244914 100644 --- a/lms/static/certificates/sass/_components.scss +++ b/lms/static/certificates/sass/_components.scss @@ -58,7 +58,6 @@ @extend %depth-card; } - // ------------------------------ // #IMAGES // ------------------------------ @@ -554,3 +553,71 @@ } } +// ------------------------------ +// #BADGES MODAL +// ------------------------------ +.badges-overlay { + position: fixed; + top: 0; + left: 0; + z-index: z-index(front); + background-color: palette(grayscale, trans); /* dim the background */ + width: 100%; + height: 100%; + vertical-align: middle; + + .badges-modal { + @extend %copy-large; + box-sizing: content-box; + position: fixed; + top: spacing-vertical(large); + right: 0; + left: 0; + z-index: z-index(very-front); + max-width: 50%; + margin-right: auto; + margin-left: auto; + border-top: rem(10) solid palette(primary, light); + background: palette(grayscale, white); + padding-right: spacing-horizontal(large); + padding-left: spacing-horizontal(large); + overflow-x: hidden; + color: palette(grayscale, dark); + + .close { + position: absolute; + right: spacing-horizontal(mid-small); + top: spacing-vertical(small); + font-weight: font-weight(bold); + cursor: pointer; + } + + .badges-steps { + display: table; + } + + .image-container{ + // Lines the image up with the content of the above list. + @include ltr { + @include padding-left(2em); + } + @include rtl { + @include padding-right(1em); + float: right; + } + } + + .backpack-logo { + @include float(right); + @include margin-left(spacing-horizontal(small)); + } + } +} + +.modal-hr { + display: block; + border: none; + background-color: palette(grayscale, light); + height: rem(2); + width: 100%; +} diff --git a/lms/static/certificates/sass/_overrides.scss b/lms/static/certificates/sass/_overrides.scss index c8bdb22842..4b6bb5aac4 100644 --- a/lms/static/certificates/sass/_overrides.scss +++ b/lms/static/certificates/sass/_overrides.scss @@ -10,10 +10,6 @@ // ------------------------------ // #TEMP // ------------------------------ -// temporarily hidden banner actions -.action-share-mozillaopenbadges { - display: none !important; -} // ------------------------------ // #DEVELOPERS diff --git a/lms/static/images/default-badges/honor.png b/lms/static/images/default-badges/honor.png new file mode 100644 index 0000000000..e37fb42ebc Binary files /dev/null and b/lms/static/images/default-badges/honor.png differ diff --git a/lms/static/images/default-badges/professional.png b/lms/static/images/default-badges/professional.png new file mode 100644 index 0000000000..1aaf981207 Binary files /dev/null and b/lms/static/images/default-badges/professional.png differ diff --git a/lms/static/images/default-badges/verified.png b/lms/static/images/default-badges/verified.png new file mode 100644 index 0000000000..dbda67ee5c Binary files /dev/null and b/lms/static/images/default-badges/verified.png differ diff --git a/lms/static/js/certificates/certificates.js b/lms/static/js/certificates/certificates.js new file mode 100644 index 0000000000..f935bd26ed --- /dev/null +++ b/lms/static/js/certificates/certificates.js @@ -0,0 +1,10 @@ +$(function () { + 'use strict'; + $('.action-share-mozillaopenbadges').click(function (event) { + $('.badges-overlay').fadeIn(); + event.preventDefault(); + }); + $('.badges-modal .close').click(function () { + $('.badges-overlay').fadeOut(); + }); +}); \ No newline at end of file diff --git a/lms/templates/certificates/_accomplishment-banner.html b/lms/templates/certificates/_accomplishment-banner.html index 5a2d9865e4..6fab797192 100644 --- a/lms/templates/certificates/_accomplishment-banner.html +++ b/lms/templates/certificates/_accomplishment-banner.html @@ -8,11 +8,13 @@

${accomplishment_banner_congrats}

+ %if badge:

${_("Share on:")}

+ %endif

Take this with you:

<%include file="_assets-secondary.html" /> + %if badge: + <%include file="_badges-modal.html" /> + %endif diff --git a/lms/urls.py b/lms/urls.py index e6f0bc5229..ad6dbb3140 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -634,6 +634,17 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): 'certificates.views.render_html_view', name='cert_html_view'), ) +BADGE_SHARE_TRACKER_URL = url( + r'^certificates/badge_share_tracker/{}/(?P[^/]+)/(?P[^/]+)/$'.format( + settings.COURSE_ID_PATTERN + ), + 'certificates.views.track_share_redirect', + name='badge_share_tracker' +) + +if settings.FEATURES.get('ENABLE_OPENBADGES', False): + urlpatterns += (BADGE_SHARE_TRACKER_URL,) + # XDomain proxy urlpatterns += ( url(r'^xdomain_proxy.html$', 'cors_csrf.views.xdomain_proxy', name='xdomain_proxy'),