Commit 112a1435 by Jonathan Piacenti

Refactor badging, move into its own app.

parent cc4349e9
"""
Admin registration for Badge Models
"""
from django.contrib import admin
from badges.models import CourseCompleteImageConfiguration
admin.site.register(CourseCompleteImageConfiguration)
"""
Badge Awarding backend for Badgr-Server.
"""
import hashlib
import logging
import mimetypes
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError
from badges.backends.base import BadgeBackend
from eventtracking import tracker
from badges.models import BadgeAssertion
MAX_SLUG_LENGTH = 255
LOGGER = logging.getLogger(__name__)
class BadgrBackend(BadgeBackend):
"""
Backend for Badgr-Server by Concentric Sky. http://info.badgr.io/
"""
badges = []
def __init__(self):
super(BadgrBackend, self).__init__()
if not settings.BADGR_API_TOKEN:
raise ImproperlyConfigured("BADGR_API_TOKEN not set.")
@lazy
def _base_url(self):
"""
Base URL for all API requests.
"""
return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG)
@lazy
def _badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return "{}/badges".format(self._base_url)
def _badge_url(self, slug):
"""
Get the URL for a course's badge in a given mode.
"""
return "{}/{}".format(self._badge_create_url, slug)
def _assertion_url(self, slug):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self._badge_url(slug))
def _slugify(self, badge_class):
"""
Get a compatible badge slug from the specification.
"""
slug = badge_class.issuing_component + badge_class.slug
if badge_class.issuing_component and badge_class.course_id:
# Make this unique to the course, and down to 64 characters.
# We don't do this to badges without issuing_component set for backwards compatibility.
slug = hashlib.sha256(slug + unicode(badge_class.course_id)).hexdigest()
if len(slug) > MAX_SLUG_LENGTH:
# Will be 64 characters.
slug = hashlib.sha256(slug).hexdigest()
return slug
def _log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
u"Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n"
u"and data values %r\n"
u"Response status was %s.\n%s",
response.request.url, response.request.headers,
data,
response.status_code, response.content
)
raise
def _create_badge(self, badge_class):
"""
Create the badge class on Badgr.
"""
image = badge_class.image
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
u"Could not determine content-type of image! Make sure it is a properly named .png file. "
u"Filename was: {}".format(image.name)
)
files = {'image': (image.name, image, content_type)}
data = {
'name': badge_class.display_name,
'criteria': badge_class.criteria,
'slug': self._slugify(badge_class),
'description': badge_class.description,
}
result = requests.post(
self._badge_create_url, headers=self._get_headers(), data=data, files=files,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(result, data)
def _send_assertion_created_event(self, user, assertion):
"""
Send an analytics event to record the creation of a badge assertion.
"""
tracker.emit(
'edx.badge.assertion.created', {
'user_id': user.id,
'badge_slug': assertion.badge_class.slug,
'badge_name': assertion.badge_class.display_name,
'issuing_component': assertion.badge_class.issuing_component,
'course_id': unicode(assertion.badge_class.course_id),
'enrollment_mode': assertion.badge_class.mode,
'assertion_id': assertion.id,
'assertion_image_url': assertion.image_url,
'assertion_json_url': assertion.assertion_url,
'issuer': assertion.data.get('issuer'),
}
)
def _create_assertion(self, badge_class, user, evidence_url):
"""
Register an assertion with the Badgr server for a particular user for a specific class.
"""
data = {
'email': user.email,
'evidence': evidence_url,
}
response = requests.post(
self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class)
assertion.data = response.json()
assertion.backend = 'BadgrBackend'
assertion.image_url = assertion.data['image']
assertion.assertion_url = assertion.data['json']['id']
assertion.save()
self._send_assertion_created_event(user, assertion)
return assertion
@staticmethod
def _get_headers():
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)}
def _ensure_badge_created(self, badge_class):
"""
Verify a badge has been created for this badge class, and create it if not.
"""
slug = self._slugify(badge_class)
if slug in BadgrBackend.badges:
return
response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
if response.status_code != 200:
self._create_badge(badge_class)
BadgrBackend.badges.append(slug)
def award(self, badge_class, user, evidence_url=None):
self._ensure_badge_created(badge_class)
return self._create_assertion(badge_class, user, evidence_url)
"""
Base class for badge backends.
"""
from abc import ABCMeta, abstractmethod
class BadgeBackend(object):
"""
Defines the interface for badging backends.
"""
__metaclass__ = ABCMeta
@abstractmethod
def award(self, badge_class, user, evidence_url=None):
"""
Create a badge assertion for the user using this backend.
"""
"""
Helper functions for the course complete event that was originally included with the Badging MVP.
"""
import hashlib
from django.core.urlresolvers import reverse
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext_lazy as _
from badges.utils import site_prefix
# NOTE: As these functions are carry-overs from the initial badging implementation, they are used in
# migrations. Please check the badge migrations when changing any of these functions.
def course_slug(course_key, mode):
"""
Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge
type, awarded on course completion.
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
digest = hashlib.sha256(u"{}{}".format(unicode(course_key), unicode(mode))).hexdigest()[:7]
base_slug = slugify(unicode(course_key) + u'_{}_'.format(mode))[:248]
return base_slug + digest
def badge_description(course, mode):
"""
Returns a description for the earned badge.
"""
if course.end:
return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format(
start_date=course.start.date(),
end_date=course.end.date(),
course_name=course.display_name,
course_mode=mode,
)
else:
return _(u'Completed the course "{course_name}" ({course_mode})').format(
course_name=course.display_name,
course_mode=mode,
)
def evidence_url(user_id, course_key):
"""
Generates a URL to the user's Certificate HTML view, along with a GET variable that will signal the evidence visit
event.
"""
return site_prefix() + reverse(
'certificates:html_view', kwargs={'user_id': user_id, 'course_id': unicode(course_key)}) + '?evidence_visit=1'
def criteria(course_key):
"""
Constructs the 'criteria' URL from the course about page.
"""
about_path = reverse('about_course', kwargs={'course_id': unicode(course_key)})
return u'{}{}'.format(site_prefix(), about_path)
"""
Tests for the course completion helper functions.
"""
from datetime import datetime
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from badges.events import course_complete
class CourseCompleteTestCase(ModuleStoreTestCase):
"""
Tests for the course completion helper functions.
"""
def setUp(self, **kwargs):
super(CourseCompleteTestCase, self).setUp()
# Need key to be deterministic to test slugs.
self.course = CourseFactory.create(
org='edX', course='course_test', run='test_run', display_name='Badged',
start=datetime(year=2015, month=5, day=19),
end=datetime(year=2015, month=5, day=20)
)
self.course_key = self.course.location.course_key
def test_slug(self):
"""
Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause
the handler to lose track of all badges it made in the past.
"""
self.assertEqual(
course_complete.course_slug(self.course_key, 'honor'),
'edxcourse_testtest_run_honor_fc5519b'
)
self.assertEqual(
course_complete.course_slug(self.course_key, 'verified'),
'edxcourse_testtest_run_verified_a199ec0'
)
def test_dated_description(self):
"""
Verify that a course with start/end dates contains a description with them.
"""
self.assertEqual(
course_complete.badge_description(self.course, 'honor'),
'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)'
)
def test_self_paced_description(self):
"""
Verify that a badge created for a course with no end date gets a different description.
"""
self.course.end = None
self.assertEqual(
course_complete.badge_description(self.course, 'honor'),
'Completed the course "Badged" (honor)'
)
def test_evidence_url(self):
"""
Make sure the evidence URL points to the right place.
"""
user = UserFactory.create()
self.assertEqual(
'https://edx.org/certificates/user/{user_id}/course/{course_key}?evidence_visit=1'.format(
user_id=user.id, course_key=self.course_key
),
course_complete.evidence_url(user.id, self.course_key)
)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonfield.fields
import badges.models
from django.conf import settings
import xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BadgeAssertion',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('data', jsonfield.fields.JSONField()),
('backend', models.CharField(max_length=50)),
('image_url', models.URLField()),
('assertion_url', models.URLField()),
],
),
migrations.CreateModel(
name='BadgeClass',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('slug', models.SlugField(max_length=255)),
('issuing_component', models.SlugField(default=b'', blank=True)),
('display_name', models.CharField(max_length=255)),
('course_id', xmodule_django.models.CourseKeyField(default=None, max_length=255, blank=True)),
('description', models.TextField()),
('criteria', models.TextField()),
('mode', models.CharField(default=b'', max_length=100, blank=True)),
('image', models.ImageField(upload_to=b'badge_classes', validators=[badges.models.validate_badge_image])),
],
),
migrations.CreateModel(
name='CourseCompleteImageConfiguration',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)),
('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'course_complete_badges', validators=[badges.models.validate_badge_image])),
('default', models.BooleanField(default=False, help_text='Set this value to True if you want this image to be the default image for any course modes that do not have a specified badge image. You can have only one default image.')),
],
),
migrations.AlterUniqueTogether(
name='badgeclass',
unique_together=set([('slug', 'issuing_component', 'course_id')]),
),
migrations.AddField(
model_name='badgeassertion',
name='badge_class',
field=models.ForeignKey(to='badges.BadgeClass'),
),
migrations.AddField(
model_name='badgeassertion',
name='user',
field=models.ForeignKey(to=settings.AUTH_USER_MODEL),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json
import os
from django.db import migrations, models
def forwards(apps, schema_editor):
"""
Migrate the initial badge classes, assertions, and course image configurations from certificates.
"""
from django.core.files.base import ContentFile
from xmodule.modulestore.django import modulestore
from badges.events import course_complete
db_alias = schema_editor.connection.alias
# This will need to be changed if badges/certificates get moved out of the default db for some reason.
if db_alias != 'default':
return
classes = {}
OldBadgeAssertion = apps.get_model("certificates", "BadgeAssertion")
BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration")
BadgeAssertion = apps.get_model("badges", "BadgeAssertion")
BadgeClass = apps.get_model("badges", "BadgeClass")
CourseCompleteImageConfiguration = apps.get_model("badges", "CourseCompleteImageConfiguration")
for badge in OldBadgeAssertion.objects.all():
if (badge.course_id, badge.mode) not in classes:
course = modulestore().get_course(badge.course_id)
image_config = BadgeImageConfiguration.objects.get(mode=badge.mode)
icon = image_config.icon
badge_class = BadgeClass(
display_name=course.display_name,
criteria=course_complete.evidence_url(badge.user_id, badge.course_id),
description=course_complete.badge_description(course, badge.mode),
slug=course_complete.course_slug(badge.course_id, badge.mode),
mode=image_config.mode,
course_id=badge.course_id,
)
file_content = ContentFile(icon.read())
badge_class._meta.get_field('image').generate_filename = \
lambda inst, fn: os.path.join('badge_classes', fn)
badge_class.image.save(icon.name, file_content)
badge_class.save()
classes[(badge.course_id, badge.mode)] = badge_class
if isinstance(badge.data, basestring):
data = badge.data
else:
data = json.dumps(badge.data)
BadgeAssertion(
user_id=badge.user_id,
badge_class=classes[(badge.course_id, badge.mode)],
data=data,
backend='BadgrBackend',
image_url=badge.data['image'],
assertion_url=badge.data['json']['id'],
).save()
for configuration in BadgeImageConfiguration.objects.all():
file_content = ContentFile(configuration.icon.read())
new_conf = CourseCompleteImageConfiguration(
default=configuration.default,
mode=configuration.mode,
)
new_conf.icon.save(configuration.icon.name, file_content)
new_conf.save()
#
def backwards(apps, schema_editor):
from django.core.files.base import ContentFile
OldBadgeAssertion = apps.get_model("certificates", "BadgeAssertion")
BadgeAssertion = apps.get_model("badges", "BadgeAssertion")
BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration")
CourseCompleteImageConfiguration = apps.get_model("badges", "CourseCompleteImageConfiguration")
for badge in BadgeAssertion.objects.all():
if not badge.badge_class.mode:
# Can't preserve old badges without modes.
continue
if isinstance(badge.data, basestring):
data = badge.data
else:
data = json.dumps(badge.data)
OldBadgeAssertion(
user_id=badge.user_id,
course_id=badge.badge_class.course_id,
mode=badge.badge_class.mode,
data=data,
).save()
for configuration in CourseCompleteImageConfiguration.objects.all():
file_content = ContentFile(configuration.icon.read())
new_conf = BadgeImageConfiguration(
default=configuration.default,
mode=configuration.mode,
)
new_conf.icon.save(configuration.icon.name, file_content)
new_conf.save()
class Migration(migrations.Migration):
dependencies = [
('badges', '0001_initial'),
('certificates', '0007_certificateinvalidation')
]
operations = [
migrations.RunPython(forwards, backwards)
]
"""
Database models for the badges app
"""
from importlib import import_module
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from lazy import lazy
from xmodule_django.models import CourseKeyField
from jsonfield import JSONField
def validate_badge_image(image):
"""
Validates that a particular image is small enough, of the right type, and square to be a badge.
"""
if image.width != image.height:
raise ValidationError(_(u"The badge image must be square."))
if not image.size < (250 * 1024):
raise ValidationError(_(u"The badge image file size must be less than 250KB."))
def validate_lowercase(string):
"""
Validates that a string is lowercase.
"""
if not string == string.lower():
raise ValidationError(_(u"This value must be all lowercase."))
class BadgeClass(models.Model):
"""
Specifies a badge class to be registered with a backend.
"""
slug = models.SlugField(max_length=255, validators=[validate_lowercase])
issuing_component = models.SlugField(max_length=50, default='', blank=True, validators=[validate_lowercase])
display_name = models.CharField(max_length=255)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
description = models.TextField()
criteria = models.TextField()
# Mode a badge was awarded for. Included for legacy/migration purposes.
mode = models.CharField(max_length=100, default='', blank=True)
image = models.ImageField(upload_to='badge_classes', validators=[validate_badge_image])
def __unicode__(self):
return u"<Badge '{slug}' for '{issuing_component}'>".format(
slug=self.slug, issuing_component=self.issuing_component
)
@classmethod
def get_badge_class(
cls, slug, issuing_component, display_name, description, criteria, image_file_handle,
mode='', course_id=None, create=True
):
"""
Looks up a badge class by its slug, issuing component, and course_id and returns it should it exist.
If it does not exist, and create is True, creates it according to the arguments. Otherwise, returns None.
"""
slug = slug.lower()
issuing_component = issuing_component.lower()
if not course_id:
course_id = CourseKeyField.Empty
try:
return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id)
except cls.DoesNotExist:
if not create:
return None
badge_class = cls(
slug=slug,
issuing_component=issuing_component,
display_name=display_name,
course_id=course_id,
mode=mode,
description=description,
criteria=criteria,
)
badge_class.image.save(image_file_handle.name, image_file_handle)
badge_class.full_clean()
badge_class.save()
return badge_class
@lazy
def backend(self):
"""
Loads the badging backend.
"""
module, klass = settings.BADGING_BACKEND.rsplit('.', 1)
module = import_module(module)
return getattr(module, klass)()
def get_for_user(self, user):
"""
Get the assertion for this badge class for this user, if it has been awarded.
"""
return self.badgeassertion_set.filter(user=user)
def award(self, user, evidence_url=None):
"""
Contacts the backend to have a badge assertion created for this badge class for this user.
"""
return self.backend.award(self, user, evidence_url=evidence_url)
def save(self, **kwargs):
"""
Slugs must always be lowercase.
"""
self.slug = self.slug and self.slug.lower()
self.issuing_component = self.issuing_component and self.issuing_component.lower()
super(BadgeClass, self).save(**kwargs)
class Meta(object):
app_label = "badges"
unique_together = (('slug', 'issuing_component', 'course_id'),)
class BadgeAssertion(models.Model):
"""
Tracks badges on our side of the badge baking transaction
"""
user = models.ForeignKey(User)
badge_class = models.ForeignKey(BadgeClass)
data = JSONField()
backend = models.CharField(max_length=50)
image_url = models.URLField()
assertion_url = models.URLField()
def __unicode__(self):
return u"<{username} Badge Assertion for {slug} for {issuing_component}".format(
username=self.user.username, slug=self.badge_class.slug,
issuing_component=self.badge_class.issuing_component,
)
@classmethod
def assertions_for_user(cls, user, course_id=None):
"""
Get all assertions for a user, optionally constrained to a course.
"""
if course_id:
return cls.objects.filter(user=user, badge_class__course_id=course_id)
return cls.objects.filter(user=user)
class Meta(object):
app_label = "badges"
class CourseCompleteImageConfiguration(models.Model):
"""
Contains the icon configuration for badges for a specific course mode.
"""
mode = models.CharField(
max_length=125,
help_text=_(u'The course mode for this badge image. For example, "verified" or "honor".'),
unique=True,
)
icon = models.ImageField(
# Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
help_text=_(
u"Badge images must be square PNG files. The file size should be under 250KB."
),
upload_to='course_complete_badges',
validators=[validate_badge_image]
)
default = models.BooleanField(
help_text=_(
u"Set this value to True if you want this image to be the default image for any course modes "
u"that do not have a specified badge image. You can have only one default image."
),
default=False,
)
def __unicode__(self):
return u"<CourseCompleteImageConfiguration for '{mode}'{default}>".format(
mode=self.mode,
default=u" (default)" if self.default else u''
)
def clean(self):
"""
Make sure there's not more than one default.
"""
# pylint: disable=no-member
if self.default and CourseCompleteImageConfiguration.objects.filter(default=True).exclude(id=self.id):
raise ValidationError(_(u"There can be only one default image."))
@classmethod
def image_for_mode(cls, mode):
"""
Get the image for a particular mode.
"""
try:
return cls.objects.get(mode=mode).icon
except cls.DoesNotExist:
# Fall back to default, if there is one.
return cls.objects.get(default=True).icon
class Meta(object):
app_label = "badges"
"""
Factories for Badge tests
"""
from random import random
import factory
from django.core.files.base import ContentFile
from factory import DjangoModelFactory
from factory.django import ImageField
from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass
from student.tests.factories import UserFactory
def generate_dummy_image(_unused):
"""
Used for image fields to create a sane default.
"""
return ContentFile(
ImageField()._make_data( # pylint: disable=protected-access
{'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'}
), 'test.png'
)
class CourseCompleteImageConfigurationFactory(DjangoModelFactory):
"""
Factory for BadgeImageConfigurations
"""
class Meta(object):
model = CourseCompleteImageConfiguration
mode = 'honor'
icon = factory.LazyAttribute(generate_dummy_image)
class BadgeClassFactory(DjangoModelFactory):
"""
Factory for BadgeClass
"""
class Meta(object):
model = BadgeClass
slug = 'test_slug'
issuing_component = 'test_component'
display_name = 'Test Badge'
description = "Yay! It's a test badge."
criteria = 'https://example.com/syllabus'
mode = 'honor'
image = factory.LazyAttribute(generate_dummy_image)
class RandomBadgeClassFactory(BadgeClassFactory):
"""
Same as BadgeClassFactory, but randomize the slug.
"""
slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()))
class BadgeAssertionFactory(DjangoModelFactory):
"""
Factory for BadgeAssertions
"""
class Meta(object):
model = BadgeAssertion
user = factory.SubFactory(UserFactory)
badge_class = factory.SubFactory(RandomBadgeClassFactory)
data = {}
assertion_url = 'http://example.com/example.json'
image_url = 'http://example.com/image.png'
"""
Utility functions used by the badging app.
"""
from django.conf import settings
def site_prefix():
"""
Get the prefix for the site URL-- protocol and server name.
"""
scheme = u"https" if settings.HTTPS == "on" else u"http"
return u'{}://{}'.format(scheme, settings.SITE_NAME)
......@@ -8,7 +8,6 @@ from util.organizations_helpers import get_organizations
from certificates.models import (
CertificateGenerationConfiguration,
CertificateHtmlViewConfiguration,
BadgeImageConfiguration,
CertificateTemplate,
CertificateTemplateAsset,
GeneratedCertificate,
......@@ -61,7 +60,6 @@ class GeneratedCertificateAdmin(admin.ModelAdmin):
admin.site.register(CertificateGenerationConfiguration)
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
admin.site.register(BadgeImageConfiguration)
admin.site.register(CertificateTemplate, CertificateTemplateAdmin)
admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin)
admin.site.register(GeneratedCertificate, GeneratedCertificateAdmin)
"""
BadgeHandler object-- used to award Badges to users who have completed courses.
"""
import hashlib
import logging
import mimetypes
from eventtracking import tracker
import requests
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext as _
from django.conf import settings
from django.core.urlresolvers import reverse
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError
from certificates.models import BadgeAssertion, BadgeImageConfiguration
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
LOGGER = logging.getLogger(__name__)
class BadgeHandler(object):
"""
The only properly public method of this class is 'award'. If an alternative object is created for a different
badging service, the other methods don't need to be reproduced.
"""
# Global caching dict
badges = {}
def __init__(self, course_key):
self.course_key = course_key
assert settings.BADGR_API_TOKEN
@lazy
def base_url(self):
"""
Base URL for all API requests.
"""
return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG)
@lazy
def badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return "{}/badges".format(self.base_url)
def badge_url(self, mode):
"""
Get the URL for a course's badge in a given mode.
"""
return "{}/{}".format(self.badge_create_url, self.course_slug(mode))
def assertion_url(self, mode):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self.badge_url(mode))
def course_slug(self, mode):
"""
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
digest = hashlib.sha256(u"{}{}".format(unicode(self.course_key), unicode(mode))).hexdigest()[:7]
base_slug = slugify(unicode(self.course_key) + u'_{}_'.format(mode))[:248]
return base_slug + digest
def log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
u"Encountered an error when contacting the Badgr-Server. Request sent to %s with headers %s.\n"
u"and data values %s\n"
u"Response status was %s.\n%s",
repr(response.request.url), repr(response.request.headers),
repr(data),
response.status_code, response.body
)
raise
def get_headers(self):
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)}
def ensure_badge_created(self, mode):
"""
Verify a badge has been created for this mode of the course, and, if not, create it
"""
if self.course_slug(mode) in BadgeHandler.badges:
return
response = requests.get(self.badge_url(mode), headers=self.get_headers())
if response.status_code != 200:
self.create_badge(mode)
BadgeHandler.badges[self.course_slug(mode)] = True
@staticmethod
def badge_description(course, mode):
"""
Returns a description for the earned badge.
"""
if course.end:
return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format(
start_date=course.start.date(),
end_date=course.end.date(),
course_name=course.display_name,
course_mode=mode,
)
else:
return _(u'Completed the course "{course_name}" ({course_mode})').format(
course_name=course.display_name,
course_mode=mode,
)
def site_prefix(self):
"""
Get the prefix for the site URL-- protocol and server name.
"""
scheme = u"https" if settings.HTTPS == "on" else u"http"
return u'{}://{}'.format(scheme, settings.SITE_NAME)
def create_badge(self, mode):
"""
Create the badge spec for a course's mode.
"""
course = modulestore().get_course(self.course_key)
image = BadgeImageConfiguration.image_for_mode(mode)
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
"Could not determine content-type of image! Make sure it is a properly named .png file."
)
files = {'image': (image.name, image, content_type)}
about_path = reverse('about_course', kwargs={'course_id': unicode(self.course_key)})
data = {
'name': course.display_name,
'criteria': u'{}{}'.format(self.site_prefix(), about_path),
'slug': self.course_slug(mode),
'description': self.badge_description(course, mode)
}
result = requests.post(self.badge_create_url, headers=self.get_headers(), data=data, files=files)
self.log_if_raised(result, data)
def send_assertion_created_event(self, user, assertion):
"""
Send an analytics event to record the creation of a badge assertion.
"""
tracker.emit(
'edx.badge.assertion.created', {
'user_id': user.id,
'course_id': unicode(self.course_key),
'enrollment_mode': assertion.mode,
'assertion_id': assertion.id,
'assertion_image_url': assertion.data['image'],
'assertion_json_url': assertion.data['json']['id'],
'issuer': assertion.data['issuer'],
}
)
def create_assertion(self, user, mode):
"""
Register an assertion with the Badgr server for a particular user in a particular course mode for
this course.
"""
data = {
'email': user.email,
'evidence': self.site_prefix() + reverse(
'certificates:html_view', kwargs={'user_id': user.id, 'course_id': unicode(self.course_key)}
) + '?evidence_visit=1'
}
response = requests.post(self.assertion_url(mode), headers=self.get_headers(), data=data)
self.log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(course_id=self.course_key, user=user, mode=mode)
assertion.data = response.json()
assertion.save()
self.send_assertion_created_event(user, assertion)
def award(self, user):
"""
Award a user a badge for their work on the course.
"""
mode = CourseEnrollment.objects.get(user=user, course_id=self.course_key).mode
self.ensure_badge_created(mode)
self.create_assertion(user, mode)
......@@ -8,8 +8,9 @@ from django.core.management.base import BaseCommand, CommandError
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from certificates.models import get_completion_badge
from xmodule.modulestore.django import modulestore
from certificates.models import BadgeAssertion
from certificates.api import regenerate_user_certificates
LOGGER = logging.getLogger(__name__)
......@@ -110,6 +111,12 @@ class Command(BaseCommand):
course_id
)
badge_class = get_completion_badge(course_id, student)
badge = badge_class.get_for_user(student)
if badge:
badge.delete()
LOGGER.info(u"Cleared badge for student %s.", student.id)
# Add the certificate request to the queue
ret = regenerate_user_certificates(
student, course_id, course=course,
......@@ -118,13 +125,6 @@ class Command(BaseCommand):
insecure=options['insecure']
)
try:
badge = BadgeAssertion.objects.get(user=student, course_id=course_id)
badge.delete()
LOGGER.info(u"Cleared badge for student %s.", student.id)
except BadgeAssertion.DoesNotExist:
pass
LOGGER.info(
(
u"Added a certificate regeneration task to the XQueue "
......
......@@ -9,6 +9,7 @@ import django_extensions.db.fields
import django_extensions.db.fields.json
import django.db.models.deletion
import django.utils.timezone
from badges.models import validate_badge_image
from django.conf import settings
......@@ -34,7 +35,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)),
('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'badges', validators=[certificates.models.validate_badge_image])),
('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'badges', validators=[validate_badge_image])),
('default', models.BooleanField(default=False, help_text='Set this value to True if you want this image to be the default image for any course modes that do not have a specified badge image. You can have only one default image.')),
],
),
......
......@@ -10,8 +10,11 @@ from django.core.files import File
def forwards(apps, schema_editor):
"""Add default modes"""
BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration")
objects = BadgeImageConfiguration.objects
db_alias = schema_editor.connection.alias
# This will need to be changed if badges/certificates get moved out of the default db for some reason.
if db_alias != 'default':
return
objects = BadgeImageConfiguration.objects.using(db_alias)
if not objects.exists():
for mode in ['honor', 'verified', 'professional']:
conf = objects.create(mode=mode)
......@@ -34,5 +37,5 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(forwards,backwards)
migrations.RunPython(forwards, backwards)
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('certificates', '0007_certificateinvalidation'),
('badges', '0002_data__migrate_assertions'),
]
operations = [
migrations.AlterUniqueTogether(
name='badgeassertion',
unique_together=set([]),
),
migrations.RemoveField(
model_name='badgeassertion',
name='user',
),
migrations.DeleteModel(
name='BadgeImageConfiguration',
),
migrations.DeleteModel(
name='BadgeAssertion',
),
]
......@@ -47,23 +47,25 @@ Eligibility:
"""
import json
import logging
import os
import uuid
import os
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Count
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import CreationDateTimeField
from django_extensions.db.fields.json import JSONField
from model_utils import Choices
from model_utils.models import TimeStampedModel
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from badges.events import course_complete
from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass
from config_models.models import ConfigurationModel
from instructor_task.models import InstructorTask
from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
......@@ -96,13 +98,15 @@ class CertificateStatuses(object):
error: "error states"
}
PASSED_STATUSES = (downloadable, generating, regenerating)
@classmethod
def is_passing_status(cls, status):
"""
Given the status of a certificate, return a boolean indicating whether
the student passed the course.
"""
return status in [cls.downloadable, cls.generating]
return status in cls.PASSED_STATUSES
class CertificateSocialNetworks(object):
......@@ -893,93 +897,6 @@ class CertificateHtmlViewConfiguration(ConfigurationModel):
return json_data
class BadgeAssertion(models.Model):
"""
Tracks badges on our side of the badge baking transaction
"""
user = models.ForeignKey(User)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
# Mode a badge was awarded for.
mode = models.CharField(max_length=100)
data = JSONField()
@property
def image_url(self):
"""
Get the image for this assertion.
"""
return self.data['image']
@property
def assertion_url(self):
"""
Get the public URL for the assertion.
"""
return self.data['json']['id']
class Meta(object):
unique_together = (('course_id', 'user', 'mode'),)
app_label = "certificates"
def validate_badge_image(image):
"""
Validates that a particular image is small enough, of the right type, and square to be a badge.
"""
if image.width != image.height:
raise ValidationError(_(u"The badge image must be square."))
if not image.size < (250 * 1024):
raise ValidationError(_(u"The badge image file size must be less than 250KB."))
class BadgeImageConfiguration(models.Model):
"""
Contains the configuration for badges for a specific mode. The mode
"""
class Meta(object):
app_label = "certificates"
mode = models.CharField(
max_length=125,
help_text=_(u'The course mode for this badge image. For example, "verified" or "honor".'),
unique=True,
)
icon = models.ImageField(
# Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
help_text=_(
u"Badge images must be square PNG files. The file size should be under 250KB."
),
upload_to='badges',
validators=[validate_badge_image]
)
default = models.BooleanField(
default=False,
help_text=_(
u"Set this value to True if you want this image to be the default image for any course modes "
u"that do not have a specified badge image. You can have only one default image."
)
)
def clean(self):
"""
Make sure there's not more than one default.
"""
if self.default and BadgeImageConfiguration.objects.filter(default=True).exclude(id=self.id):
raise ValidationError(_(u"There can be only one default image."))
@classmethod
def image_for_mode(cls, mode):
"""
Get the image for a particular mode.
"""
try:
return cls.objects.get(mode=mode).icon
except cls.DoesNotExist:
# Fall back to default, if there is one.
return cls.objects.get(default=True).icon
class CertificateTemplate(TimeStampedModel):
"""A set of custom web certificate templates.
......@@ -1095,6 +1012,28 @@ class CertificateTemplateAsset(TimeStampedModel):
app_label = "certificates"
def get_completion_badge(course_id, user):
"""
Given a course key and a user, find the user's enrollment mode
and get the Course Completion badge.
"""
from student.models import CourseEnrollment
mode = CourseEnrollment.objects.filter(
user=user, course_id=course_id
).order_by('-is_active')[0].mode
course = modulestore().get_course(course_id)
return BadgeClass.get_badge_class(
slug=course_complete.course_slug(course_id, mode),
issuing_component='',
criteria=course_complete.criteria(course_id),
description=course_complete.badge_description(course, mode),
course_id=course_id,
mode=mode,
display_name=course.display_name,
image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode)
)
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
#pylint: disable=unused-argument
def create_badge(sender, user, course_key, status, **kwargs):
......@@ -1106,14 +1045,14 @@ def create_badge(sender, user, course_key, status, **kwargs):
if not modulestore().get_course(course_key).issue_badges:
LOGGER.info("Course is not configured to issue badges.")
return
if BadgeAssertion.objects.filter(user=user, course_id=course_key):
LOGGER.info("Badge already exists for this user on this course.")
badge_class = get_completion_badge(course_key, user)
if BadgeAssertion.objects.filter(user=user, badge_class=badge_class):
LOGGER.info("Completion badge already exists for this user on this course.")
# Badge already exists. Skip.
return
# Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this
# by making sure it only gets run on the callback during normal workflow.
if not status == CertificateStatuses.downloadable:
return
from .badge_handler import BadgeHandler
handler = BadgeHandler(course_key)
handler.award(user)
evidence = course_complete.evidence_url(user.id, course_key)
badge_class.award(user, evidence_url=evidence)
# Factories are self documenting
# pylint: disable=missing-docstring
import factory
from uuid import uuid4
from django.core.files.base import ContentFile
from factory.django import DjangoModelFactory, ImageField
from student.models import LinkedInAddToProfileConfiguration
from factory.django import DjangoModelFactory
from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion,
BadgeImageConfiguration, CertificateInvalidation,
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist,
CertificateInvalidation,
)
from student.models import LinkedInAddToProfileConfiguration
class GeneratedCertificateFactory(DjangoModelFactory):
......@@ -44,33 +42,6 @@ class CertificateInvalidationFactory(DjangoModelFactory):
active = True
class BadgeAssertionFactory(DjangoModelFactory):
class Meta(object):
model = BadgeAssertion
mode = 'honor'
data = {
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json',
}
class BadgeImageConfigurationFactory(DjangoModelFactory):
class Meta(object):
model = BadgeImageConfiguration
mode = 'honor'
icon = factory.LazyAttribute(
lambda _: ContentFile(
ImageField()._make_data( # pylint: disable=protected-access
{'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'}
), 'test.png'
)
)
class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
class Meta(object):
......
......@@ -8,12 +8,14 @@ from mock import patch
from course_modes.models import CourseMode
from opaque_keys.edx.locator import CourseLocator
from certificates.tests.factories import BadgeAssertionFactory
from badges.models import BadgeAssertion
from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.management.commands import resubmit_error_certificates, regenerate_user, ungenerated_certs
from certificates.models import GeneratedCertificate, CertificateStatuses, BadgeAssertion
from certificates.models import GeneratedCertificate, CertificateStatuses, get_completion_badge
class CertificateManagementTest(ModuleStoreTestCase):
......@@ -30,6 +32,7 @@ class CertificateManagementTest(ModuleStoreTestCase):
CourseFactory.create()
for __ in range(3)
]
CourseCompleteImageConfigurationFactory.create()
def _create_cert(self, course_key, user, status, mode=CourseMode.HONOR):
"""Create a certificate entry. """
......@@ -170,9 +173,10 @@ class RegenerateCertificatesTest(CertificateManagementTest):
And the badge will be deleted
"""
key = self.course.location.course_key
BadgeAssertionFactory(user=self.user, course_id=key, data={})
self._create_cert(key, self.user, CertificateStatuses.downloadable)
self.assertTrue(BadgeAssertion.objects.filter(user=self.user, course_id=key))
badge_class = get_completion_badge(key, self.user)
BadgeAssertionFactory(badge_class=badge_class, user=self.user)
self.assertTrue(BadgeAssertion.objects.filter(user=self.user, badge_class=badge_class))
self._run_command(
username=self.user.email, course=unicode(key), noop=False, insecure=False, template_file=None,
grade_value=None
......@@ -185,7 +189,7 @@ class RegenerateCertificatesTest(CertificateManagementTest):
template_file=None,
generate_pdf=True
)
self.assertFalse(BadgeAssertion.objects.filter(user=self.user, course_id=key))
self.assertFalse(BadgeAssertion.objects.filter(user=self.user, badge_class=badge_class))
@override_settings(CERT_QUEUE='test-queue')
@patch('capa.xqueue_interface.XQueueInterface.send_to_queue', spec=True)
......
"""Tests for certificate Django models. """
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.images import ImageFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.test.utils import override_settings
......@@ -13,7 +12,6 @@ from certificates.models import (
ExampleCertificateSet,
CertificateHtmlViewConfiguration,
CertificateTemplateAsset,
BadgeImageConfiguration,
EligibleCertificateManager,
GeneratedCertificate,
CertificateStatuses,
......@@ -168,55 +166,6 @@ class CertificateHtmlViewConfigurationTest(TestCase):
@attr('shard_1')
class BadgeImageConfigurationTest(TestCase):
"""
Test the validation features of BadgeImageConfiguration.
"""
def get_image(self, name):
"""
Get one of the test images from the test data directory.
"""
return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png'))
def create_clean(self, file_obj):
"""
Shortcut to create a BadgeImageConfiguration with a specific file.
"""
BadgeImageConfiguration(mode='honor', icon=file_obj).full_clean()
def test_good_image(self):
"""
Verify that saving a valid badge image is no problem.
"""
good = self.get_image('good')
BadgeImageConfiguration(mode='honor', icon=good).full_clean()
def test_unbalanced_image(self):
"""
Verify that setting an image with an uneven width and height raises an error.
"""
unbalanced = ImageFile(self.get_image('unbalanced'))
self.assertRaises(ValidationError, self.create_clean, unbalanced)
def test_large_image(self):
"""
Verify that setting an image that is too big raises an error.
"""
large = self.get_image('large')
self.assertRaises(ValidationError, self.create_clean, large)
def test_no_double_default(self):
"""
Verify that creating two configurations as default is not permitted.
"""
BadgeImageConfiguration(mode='test', icon=self.get_image('good'), default=True).save()
self.assertRaises(
ValidationError,
BadgeImageConfiguration(mode='test2', icon=self.get_image('good'), default=True).full_clean
)
@attr('shard_1')
class CertificateTemplateAssetTest(TestCase):
"""
Test Assets are uploading/saving successfully for CertificateTemplateAsset.
......
"""Tests for certificates views. """
import json
import ddt
from uuid import uuid4
from nose.plugins.attrib import attr
from mock import patch
import ddt
from django.conf import settings
from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import Client
from django.test.utils import override_settings
from nose.plugins.attrib import attr
from opaque_keys.edx.locator import CourseLocator
from openedx.core.lib.tests.assertions.events import assert_event_matches
from student.tests.factories import UserFactory
from track.tests import EventTrackingTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.testing import UrlResetMixin
from certificates.api import get_certificate_url
from certificates.models import (
......@@ -28,10 +20,9 @@ from certificates.models import (
GeneratedCertificate,
CertificateHtmlViewConfiguration,
)
from certificates.tests.factories import (
BadgeAssertionFactory,
)
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
......@@ -342,51 +333,3 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
self.assertNotIn('platform_microsite', response.content)
self.assertNotIn('http://www.microsite.org', response.content)
self.assertNotIn('This should not survive being overwritten by static content', response.content)
class TrackShareRedirectTest(UrlResetMixin, ModuleStoreTestCase, EventTrackingTestCase):
"""
Verifies the badge image share event is sent out.
"""
@patch.dict(settings.FEATURES, {"ENABLE_OPENBADGES": True})
def setUp(self):
super(TrackShareRedirectTest, self).setUp('certificates.urls')
self.client = Client()
self.course = CourseFactory.create(
org='testorg', number='run1', display_name='trackable course'
)
self.assertion = BadgeAssertionFactory(
user=self.user, course_id=self.course.id, data={
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json'
},
)
def test_social_event_sent(self):
test_url = '/certificates/badge_share_tracker/{}/social_network/{}/'.format(
unicode(self.course.id),
self.user.username,
)
self.recreate_tracker()
response = self.client.get(test_url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://www.example.com/image.png')
assert_event_matches(
{
'name': 'edx.badge.assertion.shared',
'data': {
'course_id': 'testorg/run1/trackable_course',
'social_network': 'social_network',
# pylint: disable=no-member
'assertion_id': self.assertion.id,
'assertion_json_url': 'http://www.example.com/assertion.json',
'assertion_image_url': 'http://www.example.com/image.png',
'user_id': self.user.id,
'issuer': 'http://www.example.com/issuer.json',
'enrollment_mode': 'honor'
},
},
self.get_event()
)
......@@ -3,7 +3,6 @@
import json
import ddt
import mock
from uuid import uuid4
from nose.plugins.attrib import attr
from mock import patch
......@@ -16,6 +15,7 @@ from django.test.client import Client
from django.test.utils import override_settings
from course_modes.models import CourseMode
from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory
from openedx.core.lib.tests.assertions.events import assert_event_matches
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.roles import CourseStaffRole
......@@ -31,20 +31,21 @@ from certificates.models import (
CertificateTemplate,
CertificateHtmlViewConfiguration,
CertificateTemplateAsset,
get_completion_badge
)
from certificates.tests.factories import (
CertificateHtmlViewConfigurationFactory,
LinkedInAddToProfileConfigurationFactory,
BadgeAssertionFactory,
GeneratedCertificateFactory,
)
from util import organizations_helpers as organizations_api
from django.test.client import RequestFactory
import urllib
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
FEATURES_WITH_BADGES_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy()
FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False
......@@ -105,6 +106,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
)
CertificateHtmlViewConfigurationFactory.create()
LinkedInAddToProfileConfigurationFactory.create()
CourseCompleteImageConfigurationFactory.create()
def _add_course_certificates(self, count=1, signatory_count=0, is_active=True):
"""
......@@ -333,7 +335,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
)
self.assertIn('logo_test1.png', response.content)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_TWITTER": True,
"CERTIFICATE_FACEBOOK": True,
......@@ -370,8 +372,9 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
test_org = organizations_api.add_organization(organization_data=test_organization_data)
organizations_api.add_organization_course(organization_data=test_org, course_id=unicode(self.course.id))
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
badge_class = get_completion_badge(course_id=self.course_id, user=self.user)
BadgeAssertionFactory.create(
user=self.user, course_id=self.course_id,
user=self.user, badge_class=badge_class,
)
self.course.cert_html_view_overrides = {
"logo_src": "/static/certificates/images/course_override_logo.png"
......@@ -812,8 +815,15 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
)
test_url = '{}?evidence_visit=1'.format(cert_url)
self.recreate_tracker()
badge_class = get_completion_badge(self.course_id, self.user)
assertion = BadgeAssertionFactory.create(
user=self.user, course_id=self.course_id,
user=self.user, badge_class=badge_class,
backend='DummyBackend',
image_url='http://www.example.com/image.png',
assertion_url='http://www.example.com/assertion.json',
data={
'issuer': 'http://www.example.com/issuer.json',
}
)
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
......@@ -823,6 +833,10 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
'data': {
'course_id': 'testorg/run1/refundable_course',
'assertion_id': assertion.id,
'badge_generator': u'DummyBackend',
'badge_name': u'refundable course',
'issuing_component': u'',
'badge_slug': u'testorgrun1refundable_course_honor_432f164',
'assertion_json_url': 'http://www.example.com/assertion.json',
'assertion_image_url': 'http://www.example.com/image.png',
'user_id': self.user.id,
......
......@@ -6,10 +6,11 @@ from mock import patch
from django.conf import settings
from nose.plugins.attrib import attr
from badges.tests.factories import CourseCompleteImageConfigurationFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.models import (
CertificateStatuses,
GeneratedCertificate,
......@@ -113,18 +114,18 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id))
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@patch('certificates.badge_handler.BadgeHandler', spec=True)
@patch('badges.backends.badgr.BadgrBackend', spec=True)
def test_badge_callback(self, handler):
student = UserFactory()
course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True)
CourseCompleteImageConfigurationFactory()
CourseEnrollmentFactory(user=student, course_id=course.location.course_key, mode='honor')
cert = GeneratedCertificateFactory.create(
user=student,
course_id=course.id,
status=CertificateStatuses.generating,
mode='verified'
)
# Check return value since class instance will be stored there.
self.assertFalse(handler.return_value.award.called)
cert.status = CertificateStatuses.downloadable
cert.save()
self.assertTrue(handler.return_value.award.called)
......@@ -31,15 +31,3 @@ urlpatterns = patterns(
url(r'regenerate', views.regenerate_certificate_for_user, name="regenerate_certificate_for_user"),
url(r'generate', views.generate_certificate_for_user, name="generate_certificate_for_user"),
)
if settings.FEATURES.get("ENABLE_OPENBADGES", False):
urlpatterns += (
url(
r'^badge_share_tracker/{}/(?P<network>[^/]+)/(?P<student_username>[^/]+)/$'.format(
settings.COURSE_ID_PATTERN
),
views.track_share_redirect,
name='badge_share_tracker'
),
)
......@@ -5,4 +5,3 @@ Aggregate all views exposed by the certificates app.
from .xqueue import *
from .support import *
from .webview import *
from .badges import *
"""
Certificate views for open badges.
"""
from django.shortcuts import redirect, get_object_or_404
from opaque_keys.edx.locator import CourseLocator
from util.views import ensure_valid_course_key
from eventtracking import tracker
from certificates.models import BadgeAssertion
@ensure_valid_course_key
def track_share_redirect(request__unused, course_id, network, student_username):
"""
Tracks when a user downloads a badge for sharing.
"""
course_id = CourseLocator.from_string(course_id)
assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id)
tracker.emit(
'edx.badge.assertion.shared', {
'course_id': unicode(course_id),
'social_network': network,
'assertion_id': assertion.id,
'assertion_json_url': assertion.data['json']['id'],
'assertion_image_url': assertion.image_url,
'user_id': assertion.user.id,
'enrollment_mode': assertion.mode,
'issuer': assertion.data['issuer'],
}
)
return redirect(assertion.image_url)
......@@ -13,7 +13,6 @@ from django.http import HttpResponse, Http404
from django.template import RequestContext
from django.utils.translation import ugettext as _
from django.utils.encoding import smart_str
from django.core.urlresolvers import reverse
from courseware.access import has_access
from edxmako.shortcuts import render_to_response
......@@ -43,8 +42,8 @@ from certificates.models import (
CertificateStatuses,
CertificateHtmlViewConfiguration,
CertificateSocialNetworks,
BadgeAssertion
)
get_completion_badge)
log = logging.getLogger(__name__)
......@@ -355,21 +354,37 @@ def _track_certificate_events(request, context, course, user, user_certificate):
"""
Tracks web certificate view related events.
"""
badge = context['badge']
# Badge Request Event Tracking Logic
if 'evidence_visit' in request.GET and badge:
tracker.emit(
'edx.badge.assertion.evidence_visited',
{
'user_id': user.id,
'course_id': unicode(course.id),
'enrollment_mode': badge.mode,
'assertion_id': badge.id,
'assertion_image_url': badge.data['image'],
'assertion_json_url': badge.data['json']['id'],
'issuer': badge.data['issuer'],
}
)
course_key = course.location.course_key
if 'evidence_visit' in request.GET:
badge_class = get_completion_badge(course_key, user)
badges = badge_class.get_for_user(user)
if badges:
# There should only ever be one of these.
badge = badges[0]
tracker.emit(
'edx.badge.assertion.evidence_visited',
{
'badge_name': badge.badge_class.display_name,
'badge_slug': badge.badge_class.slug,
'badge_generator': badge.backend,
'issuing_component': badge.badge_class.issuing_component,
'user_id': user.id,
'course_id': unicode(course_key),
'enrollment_mode': badge.badge_class.mode,
'assertion_id': badge.id,
'assertion_image_url': badge.image_url,
'assertion_json_url': badge.assertion_url,
'issuer': badge.data.get('issuer'),
}
)
else:
log.warn(
"Could not find badge for %s on course %s.",
user.id,
course_key,
)
# track certificate evidence_visited event for analytics when certificate_user and accessing_user are different
if request.user and request.user.id != user.id:
......@@ -425,10 +440,11 @@ def _update_badge_context(context, course, user):
"""
Updates context with badge info.
"""
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key)
except BadgeAssertion.DoesNotExist:
badge = None
badge = None
if settings.FEATURES.get('ENABLE_OPENBADGES'):
badges = get_completion_badge(course.location.course_key, user).get_for_user(user)
if badges:
badge = badges[0]
context['badge'] = badge
......
......@@ -12,7 +12,6 @@ from opaque_keys.edx.locations import BlockUsageLocator, CourseLocator, SlashSep
from lms.djangoapps.lms_xblock.runtime import quote_slashes, unquote_slashes, LmsModuleSystem
from xblock.fields import ScopeIds
from xmodule.modulestore.django import ModuleI18nService
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xblock.exceptions import NoSuchServiceError
......
......@@ -290,6 +290,7 @@ MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
BADGR_API_TOKEN = ENV_TOKENS.get('BADGR_API_TOKEN', BADGR_API_TOKEN)
BADGR_BASE_URL = ENV_TOKENS.get('BADGR_BASE_URL', BADGR_BASE_URL)
BADGR_ISSUER_SLUG = ENV_TOKENS.get('BADGR_ISSUER_SLUG', BADGR_ISSUER_SLUG)
BADGR_TIMEOUT = ENV_TOKENS.get('BADGR_TIMEOUT', BADGR_TIMEOUT)
# git repo loading environment
GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos')
......
......@@ -2022,6 +2022,9 @@ INSTALLED_APPS = (
# Learner's dashboard
'learner_dashboard',
# Needed whether or not enabled, due to migrations
'badges',
)
# Migrations which are not in the standard module "migrations"
......@@ -2265,12 +2268,17 @@ REGISTRATION_EMAIL_PATTERNS_ALLOWED = None
CERT_NAME_SHORT = "Certificate"
CERT_NAME_LONG = "Certificate of Achievement"
#################### Badgr OpenBadges generation #######################
#################### OpenBadges Settings #######################
BADGING_BACKEND = 'badges.backends.badgr.BadgrBackend'
# Be sure to set up images for course modes using the BadgeImageConfiguration model in the certificates app.
BADGR_API_TOKEN = None
# Do not add the trailing slash here.
BADGR_BASE_URL = "http://localhost:8005"
BADGR_ISSUER_SLUG = "example-issuer"
# Number of seconds to wait on the badging server when contacting it before giving up.
BADGR_TIMEOUT = 10
###################### Grade Downloads ######################
# These keys are used for all of our asynchronous downloadable files, including
......
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