1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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=_(
|
||||
|
||||
@@ -162,6 +162,7 @@ class AdvancedSettingsPage(CoursePage):
|
||||
'info_sidebar_name',
|
||||
'is_new',
|
||||
'ispublic',
|
||||
'issue_badges',
|
||||
'max_student_enrollments_allowed',
|
||||
'no_grade',
|
||||
'display_coursenumber',
|
||||
|
||||
BIN
common/test/data/badges/good.png
Normal file
|
After Width: | Height: | Size: 298 B |
BIN
common/test/data/badges/large.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
common/test/data/badges/unbalanced.png
Normal file
|
After Width: | Height: | Size: 232 B |
@@ -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)
|
||||
|
||||
195
lms/djangoapps/certificates/badge_handler.py
Normal file
@@ -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)
|
||||
@@ -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 "
|
||||
|
||||
@@ -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']
|
||||
152
lms/djangoapps/certificates/migrations/0022_default_modes.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
196
lms/djangoapps/certificates/tests/test_badge_handler.py
Normal file
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
BIN
lms/static/certificates/images/backpack-logo.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
lms/static/certificates/images/backpack-ui.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
// ------------------------------
|
||||
// #TEMP
|
||||
// ------------------------------
|
||||
// temporarily hidden banner actions
|
||||
.action-share-mozillaopenbadges {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// #DEVELOPERS
|
||||
|
||||
BIN
lms/static/images/default-badges/honor.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
lms/static/images/default-badges/professional.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
lms/static/images/default-badges/verified.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
10
lms/static/js/certificates/certificates.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,13 @@
|
||||
<div class="wrapper-copy-and-actions">
|
||||
<p class="message-copy copy copy-base emphasized">${accomplishment_banner_congrats}</p>
|
||||
<div class="message-actions">
|
||||
%if badge:
|
||||
<p class="sr-only">${_("Share on:")}</p>
|
||||
<button class="action action-share-mozillaopenbadges btn btn-overlay btn-small">
|
||||
<img class="icon icon-mozillaopenbadges" src="/static/certificates/images/ico-mozillaopenbadges.png" alt="Mozilla Open Badges Backpack">
|
||||
${_("Add to Mozilla Backpack")}
|
||||
</button>
|
||||
%endif
|
||||
<p class="sr-only">Take this with you:</p>
|
||||
<button class="action action-print btn btn-overlay btn-small" id="action-print-view">
|
||||
<i class="icon fa fa-print" aria-hidden="true"></i>
|
||||
|
||||
23
lms/templates/certificates/_badges-modal.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<script src="${static.url('js/lms-base-vendor.js')}"></script>
|
||||
<script src="${static.url('js/certificates/certificates.js')}"></script>
|
||||
<div class="badges-overlay" style="display:none;">
|
||||
<div class="badges-modal">
|
||||
<div class="close"><i class="fa fa-close" alt="Close"><input type="button" class="sr-only" value="Close"/></i></div>
|
||||
<h1 class="hd-1 emphasized">Share on Mozilla Backpack</h1>
|
||||
<p class="explanation">
|
||||
To share your certificate on Mozilla Backpack, you must first have a Backpack account.
|
||||
Complete the following steps to add your certificate to Backpack.
|
||||
</p>
|
||||
<hr class="modal-hr"/>
|
||||
<img class="backpack-logo" src="${static.url('certificates/images/backpack-logo.png')}">
|
||||
<ol class="badges-steps">
|
||||
<li class="step">Create a <a href="https://backpack.openbadges.org/" target="_blank">Mozilla Backpack</a> account, or log in to your existing account
|
||||
</li>
|
||||
<li class="step"><a href="${badge.image_url}" target="_blank">Download this image (right-click, save as)</a> and then <a href="https://backpack.openbadges.org/backpack/add" target="_blank">upload</a> it to your backpack.</li>
|
||||
</ol>
|
||||
<div class="image-container">
|
||||
<img class="badges-backpack-example" src="${static.url('certificates/images/backpack-ui.png')}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,5 +39,8 @@ course_mode_class = course_mode if course_mode else ''
|
||||
</div>
|
||||
|
||||
<%include file="_assets-secondary.html" />
|
||||
%if badge:
|
||||
<%include file="_badges-modal.html" />
|
||||
%endif
|
||||
</body>
|
||||
</html>
|
||||
|
||||
11
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<network>[^/]+)/(?P<student_username>[^/]+)/$'.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'),
|
||||
|
||||