Commit 81877300 by Jonathan Piacenti

Implement OpenBadge Generation upon Certificate generation through Badgr API

parent bb0180d3
...@@ -59,6 +59,7 @@ jscover.log.* ...@@ -59,6 +59,7 @@ jscover.log.*
common/test/data/test_unicode/static/ common/test/data/test_unicode/static/
django-pyfs django-pyfs
test_root/uploads/*.txt test_root/uploads/*.txt
test_root/uploads/badges/*.png
### Installation artifacts ### Installation artifacts
*.egg-info *.egg-info
......
...@@ -669,7 +669,14 @@ class CourseFields(object): ...@@ -669,7 +669,14 @@ class CourseFields(object):
# Ensure that courses imported from XML keep their image # Ensure that courses imported from XML keep their image
default="images_course_image.jpg" 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. ## Course level Certificate Name overrides.
cert_name_short = String( cert_name_short = String(
help=_( help=_(
......
...@@ -162,6 +162,7 @@ class AdvancedSettingsPage(CoursePage): ...@@ -162,6 +162,7 @@ class AdvancedSettingsPage(CoursePage):
'info_sidebar_name', 'info_sidebar_name',
'is_new', 'is_new',
'ispublic', 'ispublic',
'issue_badges',
'max_student_enrollments_allowed', 'max_student_enrollments_allowed',
'no_grade', 'no_grade',
'display_coursenumber', 'display_coursenumber',
......
...@@ -3,8 +3,11 @@ django admin pages for certificates models ...@@ -3,8 +3,11 @@ django admin pages for certificates models
""" """
from django.contrib import admin from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin 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(CertificateGenerationConfiguration)
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin) admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
admin.site.register(BadgeImageConfiguration)
"""
BadgeHandler object-- used to award Badges to users who have completed courses.
"""
import hashlib
import logging
import mimetypes
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext as _
import requests
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 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)})
scheme = u"https" if settings.HTTPS == "on" else u"http"
data = {
'name': course.display_name,
'criteria': u'{}://{}{}'.format(scheme, settings.SITE_NAME, 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 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}
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)
assertion.data = response.json()
assertion.save()
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 ...@@ -10,6 +10,7 @@ from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from certificates.queue import XQueueCertInterface from certificates.queue import XQueueCertInterface
from certificates.models import BadgeAssertion
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
...@@ -116,6 +117,13 @@ class Command(BaseCommand): ...@@ -116,6 +117,13 @@ class Command(BaseCommand):
template_file=options['template_file'] 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( LOGGER.info(
( (
u"Added a certificate regeneration task to the XQueue " u"Added a certificate regeneration task to the XQueue "
......
# -*- 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
# -*- 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: ...@@ -47,23 +47,27 @@ Eligibility:
""" """
from datetime import datetime from datetime import datetime
import json import json
import logging
import uuid import uuid
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings 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 import Choices
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from xmodule.modulestore.django import modulestore
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from xmodule_django.models import CourseKeyField, NoneToEmptyManager from xmodule_django.models import CourseKeyField, NoneToEmptyManager
from util.milestones_helpers import fulfill_course_milestone from util.milestones_helpers import fulfill_course_milestone
from course_modes.models import CourseMode from course_modes.models import CourseMode
LOGGER = logging.getLogger(__name__)
class CertificateStatuses(object): class CertificateStatuses(object):
deleted = 'deleted' deleted = 'deleted'
...@@ -331,7 +335,7 @@ class ExampleCertificate(TimeStampedModel): ...@@ -331,7 +335,7 @@ class ExampleCertificate(TimeStampedModel):
description = models.CharField( description = models.CharField(
max_length=255, max_length=255,
help_text=ugettext_lazy( help_text=_(
u"A human-readable description of the example certificate. " u"A human-readable description of the example certificate. "
u"For example, 'verified' or 'honor' to differentiate between " u"For example, 'verified' or 'honor' to differentiate between "
u"two types of certificates." u"two types of certificates."
...@@ -346,7 +350,7 @@ class ExampleCertificate(TimeStampedModel): ...@@ -346,7 +350,7 @@ class ExampleCertificate(TimeStampedModel):
default=_make_uuid, default=_make_uuid,
db_index=True, db_index=True,
unique=True, unique=True,
help_text=ugettext_lazy( help_text=_(
u"A unique identifier for the example certificate. " u"A unique identifier for the example certificate. "
u"This is used when we receive a response from the queue " u"This is used when we receive a response from the queue "
u"to determine which example certificate was processed." u"to determine which example certificate was processed."
...@@ -357,7 +361,7 @@ class ExampleCertificate(TimeStampedModel): ...@@ -357,7 +361,7 @@ class ExampleCertificate(TimeStampedModel):
max_length=255, max_length=255,
default=_make_uuid, default=_make_uuid,
db_index=True, db_index=True,
help_text=ugettext_lazy( help_text=_(
u"An access key for the example certificate. " u"An access key for the example certificate. "
u"This is used when we receive a response from the queue " u"This is used when we receive a response from the queue "
u"to validate that the sender is the same entity we asked " u"to validate that the sender is the same entity we asked "
...@@ -368,12 +372,12 @@ class ExampleCertificate(TimeStampedModel): ...@@ -368,12 +372,12 @@ class ExampleCertificate(TimeStampedModel):
full_name = models.CharField( full_name = models.CharField(
max_length=255, max_length=255,
default=EXAMPLE_FULL_NAME, 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( template = models.CharField(
max_length=255, 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 # Outputs from certificate generation
...@@ -385,20 +389,20 @@ class ExampleCertificate(TimeStampedModel): ...@@ -385,20 +389,20 @@ class ExampleCertificate(TimeStampedModel):
(STATUS_SUCCESS, 'Success'), (STATUS_SUCCESS, 'Success'),
(STATUS_ERROR, 'Error') (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( error_reason = models.TextField(
null=True, null=True,
default=None, 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( download_url = models.CharField(
max_length=255, max_length=255,
null=True, null=True,
default=None, 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): def update_status(self, status, error_reason=None, download_url=None):
...@@ -574,3 +578,105 @@ class CertificateHtmlViewConfiguration(ConfigurationModel): ...@@ -574,3 +578,105 @@ class CertificateHtmlViewConfiguration(ConfigurationModel):
instance = cls.current() instance = cls.current()
json_data = json.loads(instance.configuration) if instance.enabled else {} json_data = json.loads(instance.configuration) if instance.enabled else {}
return json_data 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']
class Meta(object):
"""
Meta information for Django's construction of the model.
"""
unique_together = (('course_id', 'user'),)
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)
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 student.models import LinkedInAddToProfileConfiguration
from certificates.models import ( 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): class GeneratedCertificateFactory(DjangoModelFactory):
FACTORY_FOR = GeneratedCertificate FACTORY_FOR = GeneratedCertificate
...@@ -27,6 +28,20 @@ class CertificateWhitelistFactory(DjangoModelFactory): ...@@ -27,6 +28,20 @@ class CertificateWhitelistFactory(DjangoModelFactory):
whitelist = True 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): class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
FACTORY_FOR = CertificateHtmlViewConfiguration FACTORY_FOR = CertificateHtmlViewConfiguration
......
"""
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 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_create_assertion(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'])
self.assertEqual(kwargs['data'], {'email': 'example@example.com'})
badge = BadgeAssertion.objects.get(user=self.user, course_id=self.course.location.course_key)
self.assertEqual(badge.data, result)
self.assertEqual(badge.image_url, 'http://www.example.com/example.png')
...@@ -2,28 +2,64 @@ ...@@ -2,28 +2,64 @@
import ddt import ddt
from django.core.management.base import CommandError from django.core.management.base import CommandError
from nose.plugins.attrib import attr 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 opaque_keys.edx.locator import CourseLocator
from certificates.tests.factories import BadgeAssertionFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.management.commands import resubmit_error_certificates from certificates.management.commands import resubmit_error_certificates, regenerate_user
from certificates.models import GeneratedCertificate, CertificateStatuses from certificates.models import GeneratedCertificate, CertificateStatuses, BadgeAssertion
@attr('shard_1') class CertificateManagementTest(ModuleStoreTestCase):
@ddt.ddt """
class ResubmitErrorCertificatesTest(ModuleStoreTestCase): Base test class for Certificate Management command tests.
"""Tests for the resubmit_error_certificates management command. """ """
# Override with the command module you wish to test.
command = resubmit_error_certificates
def setUp(self): def setUp(self):
super(ResubmitErrorCertificatesTest, self).setUp() super(CertificateManagementTest, self).setUp()
self.user = UserFactory.create() self.user = UserFactory.create()
self.courses = [ self.courses = [
CourseFactory.create() CourseFactory.create()
for __ in range(3) 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): def test_resubmit_error_certificate(self):
# Create a certificate with status 'error' # Create a certificate with status 'error'
self._create_cert(self.courses[0].id, self.user, CertificateStatuses.error) self._create_cert(self.courses[0].id, self.user, CertificateStatuses.error)
...@@ -105,27 +141,39 @@ class ResubmitErrorCertificatesTest(ModuleStoreTestCase): ...@@ -105,27 +141,39 @@ class ResubmitErrorCertificatesTest(ModuleStoreTestCase):
# since the course doesn't actually exist. # since the course doesn't actually exist.
self._assert_cert_status(phantom_course, self.user, CertificateStatuses.error) 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
)
# Create the certificate
GeneratedCertificate.objects.create(
user=user,
course_id=course_key,
status=status
)
def _run_command(self, *args, **kwargs): @attr('shard_1')
"""Run the management command to generate a fake cert. """ class RegenerateCertificatesTest(CertificateManagementTest):
command = resubmit_error_certificates.Command() """
return command.handle(*args, **kwargs) Tests for regenerating certificates.
"""
command = regenerate_user
def _assert_cert_status(self, course_key, user, expected_status): def setUp(self):
"""Check the status of a certificate. """ """
cert = GeneratedCertificate.objects.get(user=user, course_id=course_key) We just need one course here.
self.assertEqual(cert.status, expected_status) """
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
)
xqueue.return_value.regen_cert.assert_called_with(
self.user, key, course=self.course, forced_grade=None, template_file=None
)
self.assertFalse(BadgeAssertion.objects.filter(user=self.user, course_id=key))
"""Tests for certificate Django models. """ """Tests for certificate Django models. """
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.images import ImageFile
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
# pylint: disable=no-name-in-module
from path import path
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from certificates.models import ( from certificates.models import (
ExampleCertificate, ExampleCertificate,
ExampleCertificateSet, ExampleCertificateSet,
CertificateHtmlViewConfiguration CertificateHtmlViewConfiguration,
) BadgeImageConfiguration)
FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy() FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy()
FEATURES_INVALID_FILE_PATH['CERTS_HTML_VIEW_CONFIG_PATH'] = 'invalid/path/to/config.json' 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') @attr('shard_1')
class ExampleCertificateTest(TestCase): class ExampleCertificateTest(TestCase):
...@@ -151,3 +160,52 @@ class CertificateHtmlViewConfigurationTest(TestCase): ...@@ -151,3 +160,52 @@ class CertificateHtmlViewConfigurationTest(TestCase):
self.config.configuration = '' self.config.configuration = ''
self.config.save() self.config.save()
self.assertEquals(self.config.get_config(), {}) 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
)
...@@ -82,3 +82,20 @@ class CertificatesModelTest(ModuleStoreTestCase): ...@@ -82,3 +82,20 @@ class CertificatesModelTest(ModuleStoreTestCase):
completed_milestones = milestones_achieved_by_user(student, unicode(pre_requisite_course.id)) completed_milestones = milestones_achieved_by_user(student, unicode(pre_requisite_course.id))
self.assertEqual(len(completed_milestones), 1) self.assertEqual(len(completed_milestones), 1)
self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id)) 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)
...@@ -262,6 +262,11 @@ ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL") ...@@ -262,6 +262,11 @@ ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL")
FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL") FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL")
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) 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 loading environment
GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos') GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos')
GIT_IMPORT_STATIC = ENV_TOKENS.get('GIT_IMPORT_STATIC', True) GIT_IMPORT_STATIC = ENV_TOKENS.get('GIT_IMPORT_STATIC', True)
......
...@@ -399,6 +399,9 @@ FEATURES = { ...@@ -399,6 +399,9 @@ FEATURES = {
# How many seconds to show the bumper again, default is 7 days: # How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600, '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 # Ignore static asset files on import which match this pattern
...@@ -2024,6 +2027,13 @@ REGISTRATION_EXTRA_FIELDS = { ...@@ -2024,6 +2027,13 @@ REGISTRATION_EXTRA_FIELDS = {
CERT_NAME_SHORT = "Certificate" CERT_NAME_SHORT = "Certificate"
CERT_NAME_LONG = "Certificate of Achievement" 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 ###################### ###################### Grade Downloads ######################
GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment