Commit cda26bb9 by Kelketek

Merge pull request #8173 from edx/badges

OpenBadges MVP (SOL-768)
parents 9008548c d499b224
...@@ -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
......
...@@ -684,7 +684,14 @@ class CourseFields(object): ...@@ -684,7 +684,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 eventtracking import tracker
import requests
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext as _
from django.conf import settings
from django.core.urlresolvers import reverse
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError
from certificates.models import BadgeAssertion, BadgeImageConfiguration
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
LOGGER = logging.getLogger(__name__)
class BadgeHandler(object):
"""
The only properly public method of this class is 'award'. If an alternative object is created for a different
badging service, the other methods don't need to be reproduced.
"""
# Global caching dict
badges = {}
def __init__(self, course_key):
self.course_key = course_key
assert settings.BADGR_API_TOKEN
@lazy
def base_url(self):
"""
Base URL for all API requests.
"""
return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG)
@lazy
def badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return "{}/badges".format(self.base_url)
def badge_url(self, mode):
"""
Get the URL for a course's badge in a given mode.
"""
return "{}/{}".format(self.badge_create_url, self.course_slug(mode))
def assertion_url(self, mode):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self.badge_url(mode))
def course_slug(self, mode):
"""
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
digest = hashlib.sha256(u"{}{}".format(unicode(self.course_key), unicode(mode))).hexdigest()[:7]
base_slug = slugify(unicode(self.course_key) + u'_{}_'.format(mode))[:248]
return base_slug + digest
def log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
u"Encountered an error when contacting the Badgr-Server. Request sent to %s with headers %s.\n"
u"and data values %s\n"
u"Response status was %s.\n%s",
repr(response.request.url), repr(response.request.headers),
repr(data),
response.status_code, response.body
)
raise
def get_headers(self):
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)}
def ensure_badge_created(self, mode):
"""
Verify a badge has been created for this mode of the course, and, if not, create it
"""
if self.course_slug(mode) in BadgeHandler.badges:
return
response = requests.get(self.badge_url(mode), headers=self.get_headers())
if response.status_code != 200:
self.create_badge(mode)
BadgeHandler.badges[self.course_slug(mode)] = True
@staticmethod
def badge_description(course, mode):
"""
Returns a description for the earned badge.
"""
if course.end:
return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format(
start_date=course.start.date(),
end_date=course.end.date(),
course_name=course.display_name,
course_mode=mode,
)
else:
return _(u'Completed the course "{course_name}" ({course_mode})').format(
start_date=course.display_name,
course_mode=mode,
)
def site_prefix(self):
"""
Get the prefix for the site URL-- protocol and server name.
"""
scheme = u"https" if settings.HTTPS == "on" else u"http"
return u'{}://{}'.format(scheme, settings.SITE_NAME)
def create_badge(self, mode):
"""
Create the badge spec for a course's mode.
"""
course = modulestore().get_course(self.course_key)
image = BadgeImageConfiguration.image_for_mode(mode)
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
"Could not determine content-type of image! Make sure it is a properly named .png file."
)
files = {'image': (image.name, image, content_type)}
about_path = reverse('about_course', kwargs={'course_id': unicode(self.course_key)})
data = {
'name': course.display_name,
'criteria': u'{}{}'.format(self.site_prefix(), about_path),
'slug': self.course_slug(mode),
'description': self.badge_description(course, mode)
}
result = requests.post(self.badge_create_url, headers=self.get_headers(), data=data, files=files)
self.log_if_raised(result, data)
def send_assertion_created_event(self, user, assertion):
"""
Send an analytics event to record the creation of a badge assertion.
"""
tracker.emit(
'edx.badges.assertion.created', {
'user_id': user.id,
'course_id': unicode(self.course_key),
'enrollment_mode': assertion.mode,
'assertion_id': assertion.id,
'assertion_image_url': assertion.data['image'],
'assertion_json_url': assertion.data['json']['id'],
'issuer': assertion.data['issuer'],
}
)
def create_assertion(self, user, mode):
"""
Register an assertion with the Badgr server for a particular user in a particular course mode for
this course.
"""
data = {
'email': user.email,
'evidence': self.site_prefix() + reverse(
'cert_html_view', kwargs={'user_id': user.id, 'course_id': unicode(self.course_key)}
) + '?evidence_visit=1'
}
response = requests.post(self.assertion_url(mode), headers=self.get_headers(), data=data)
self.log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(course_id=self.course_key, user=user, mode=mode)
assertion.data = response.json()
assertion.save()
self.send_assertion_created_event(user, assertion)
def award(self, user):
"""
Award a user a badge for their work on the course.
"""
mode = CourseEnrollment.objects.get(user=user, course_id=self.course_key).mode
self.ensure_badge_created(mode)
self.create_assertion(user, mode)
...@@ -10,6 +10,7 @@ from opaque_keys.edx.keys import CourseKey ...@@ -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 "
......
...@@ -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,113 @@ class CertificateHtmlViewConfiguration(ConfigurationModel): ...@@ -574,3 +578,113 @@ 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']
@property
def assertion_url(self):
"""
Get the public URL for the assertion.
"""
return self.data['json']['id']
class Meta(object):
"""
Meta information for Django's construction of the model.
"""
unique_together = (('course_id', 'user', 'mode'),)
def validate_badge_image(image):
"""
Validates that a particular image is small enough, of the right type, and square to be a badge.
"""
if image.width != image.height:
raise ValidationError(_(u"The badge image must be square."))
if not image.size < (250 * 1024):
raise ValidationError(_(u"The badge image file size must be less than 250KB."))
class BadgeImageConfiguration(models.Model):
"""
Contains the configuration for badges for a specific mode. The mode
"""
mode = models.CharField(
max_length=125,
help_text=_(u'The course mode for this badge image. For example, "verified" or "honor".'),
unique=True,
)
icon = models.ImageField(
# Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
help_text=_(
u"Badge images must be square PNG files. The file size should be under 250KB."
),
upload_to='badges',
validators=[validate_badge_image]
)
default = models.BooleanField(
help_text=_(
u"Set this value to True if you want this image to be the default image for any course modes "
u"that do not have a specified badge image. You can have only one default image."
)
)
def clean(self):
"""
Make sure there's not more than one default.
"""
# pylint: disable=no-member
if self.default and BadgeImageConfiguration.objects.filter(default=True).exclude(id=self.id):
raise ValidationError(_(u"There can be only one default image."))
@classmethod
def image_for_mode(cls, mode):
"""
Get the image for a particular mode.
"""
try:
return cls.objects.get(mode=mode).icon
except cls.DoesNotExist:
# Fall back to default, if there is one.
return cls.objects.get(default=True).icon
@receiver(post_save, sender=GeneratedCertificate)
#pylint: disable=unused-argument
def create_badge(sender, instance, **kwargs):
"""
Standard signal hook to create badges when a certificate has been generated.
"""
if not settings.FEATURES.get('ENABLE_OPENBADGES', False):
return
if not modulestore().get_course(instance.course_id).issue_badges:
LOGGER.info("Course is not configured to issue badges.")
return
if BadgeAssertion.objects.filter(user=instance.user, course_id=instance.course_id):
LOGGER.info("Badge already exists for this user on this course.")
# Badge already exists. Skip.
return
# Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this
# by making sure it only gets run on the callback during normal workflow.
if not instance.status == CertificateStatuses.downloadable:
return
from .badge_handler import BadgeHandler
handler = BadgeHandler(instance.course_id)
handler.award(instance.user)
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 openedx.core.lib.tests.assertions.events import assert_event_matches
from track.tests import EventTrackingTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from certificates.badge_handler import BadgeHandler
from certificates.tests.factories import BadgeImageConfigurationFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
BADGR_SETTINGS = {
'BADGR_API_TOKEN': '12345',
'BADGR_BASE_URL': 'https://example.com',
'BADGR_ISSUER_SLUG': 'test-issuer',
}
@override_settings(**BADGR_SETTINGS)
class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase):
"""
Tests the BadgeHandler object
"""
def setUp(self):
"""
Create a course and user to test with.
"""
super(BadgeHandlerTestCase, self).setUp()
# Need key to be deterministic to test slugs.
self.course = CourseFactory.create(
org='edX', course='course_test', run='test_run', display_name='Badged',
start=datetime(year=2015, month=5, day=19),
end=datetime(year=2015, month=5, day=20)
)
self.user = UserFactory.create(email='example@example.com')
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.location.course_key, mode='honor')
# Need for force empty this dict on each run.
BadgeHandler.badges = {}
BadgeImageConfigurationFactory()
@lazy
def handler(self):
"""
Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings
overrides aren't in place.
"""
return BadgeHandler(self.course.location.course_key)
def test_urls(self):
"""
Make sure the handler generates the correct URLs for different API tasks.
"""
self.assertEqual(self.handler.base_url, 'https://example.com/v1/issuer/issuers/test-issuer')
self.assertEqual(self.handler.badge_create_url, 'https://example.com/v1/issuer/issuers/test-issuer/badges')
self.assertEqual(
self.handler.badge_url('honor'),
'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b'
)
self.assertEqual(
self.handler.assertion_url('honor'),
'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b/assertions'
)
def check_headers(self, headers):
"""
Verify the a headers dict from a requests call matches the proper auth info.
"""
self.assertEqual(headers, {'Authorization': 'Token 12345'})
def test_slug(self):
"""
Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause
the handler to lose track of all badges it made in the past.
"""
self.assertEqual(
self.handler.course_slug('honor'),
'edxcourse_testtest_run_honor_fc5519b'
)
self.assertEqual(
self.handler.course_slug('verified'),
'edxcourse_testtest_run_verified_a199ec0'
)
def test_get_headers(self):
"""
Check to make sure the handler generates appropriate HTTP headers.
"""
self.check_headers(self.handler.get_headers())
@patch('requests.post')
def test_create_badge(self, post):
"""
Verify badge spec creation works.
"""
self.handler.create_badge('honor')
args, kwargs = post.call_args
self.assertEqual(args[0], 'https://example.com/v1/issuer/issuers/test-issuer/badges')
self.assertEqual(kwargs['files']['image'][0], BadgeImageConfiguration.objects.get(mode='honor').icon.name)
self.assertIsInstance(kwargs['files']['image'][1], ImageFieldFile)
self.assertEqual(kwargs['files']['image'][2], 'image/png')
self.check_headers(kwargs['headers'])
self.assertEqual(
kwargs['data'],
{
'name': 'Badged',
'slug': 'edxcourse_testtest_run_honor_fc5519b',
'criteria': 'https://edx.org/courses/edX/course_test/test_run/about',
'description': 'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)',
}
)
def test_ensure_badge_created_cache(self):
"""
Make sure ensure_badge_created doesn't call create_badge if we know the badge is already there.
"""
BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'] = True
self.handler.create_badge = Mock()
self.handler.ensure_badge_created('honor')
self.assertFalse(self.handler.create_badge.called)
@patch('requests.get')
def test_ensure_badge_created_checks(self, get):
response = Mock()
response.status_code = 200
get.return_value = response
self.assertNotIn('edxcourse_testtest_run_honor_fc5519b', BadgeHandler.badges)
self.handler.create_badge = Mock()
self.handler.ensure_badge_created('honor')
self.assertTrue(get.called)
args, kwargs = get.call_args
self.assertEqual(
args[0],
'https://example.com/v1/issuer/issuers/test-issuer/badges/'
'edxcourse_testtest_run_honor_fc5519b'
)
self.check_headers(kwargs['headers'])
self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'])
self.assertFalse(self.handler.create_badge.called)
@patch('requests.get')
def test_ensure_badge_created_creates(self, get):
response = Mock()
response.status_code = 404
get.return_value = response
self.assertNotIn('edxcourse_testtest_run_honor_fc5519b', BadgeHandler.badges)
self.handler.create_badge = Mock()
self.handler.ensure_badge_created('honor')
self.assertTrue(self.handler.create_badge.called)
self.assertEqual(self.handler.create_badge.call_args, call('honor'))
self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'])
@patch('requests.post')
def test_badge_creation_event(self, post):
result = {
'json': {'id': 'http://www.example.com/example'},
'image': 'http://www.example.com/example.png',
'slug': 'test_assertion_slug',
'issuer': 'https://example.com/v1/issuer/issuers/test-issuer',
}
response = Mock()
response.json.return_value = result
post.return_value = response
self.recreate_tracker()
self.handler.create_assertion(self.user, 'honor')
args, kwargs = post.call_args
self.assertEqual(
args[0],
'https://example.com/v1/issuer/issuers/test-issuer/badges/'
'edxcourse_testtest_run_honor_fc5519b/assertions'
)
self.check_headers(kwargs['headers'])
assertion = BadgeAssertion.objects.get(user=self.user, course_id=self.course.location.course_key)
self.assertEqual(assertion.data, result)
self.assertEqual(assertion.image_url, 'http://www.example.com/example.png')
self.assertEqual(kwargs['data'], {
'email': 'example@example.com',
'evidence': 'https://edx.org/certificates/user/2/course/edX/course_test/test_run?evidence_visit=1'
})
assert_event_matches({
'name': 'edx.badges.assertion.created',
'data': {
'user_id': self.user.id,
'course_id': unicode(self.course.location.course_key),
'enrollment_mode': 'honor',
'assertion_id': assertion.id,
'assertion_image_url': 'http://www.example.com/example.png',
'assertion_json_url': 'http://www.example.com/example',
'issuer': 'https://example.com/v1/issuer/issuers/test-issuer',
}
}, self.get_event())
...@@ -2,28 +2,64 @@ ...@@ -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
)
...@@ -13,16 +13,20 @@ from django.test.client import Client ...@@ -13,16 +13,20 @@ from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from opaque_keys.edx.locator import CourseLocator 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 student.tests.factories import UserFactory
from track.tests import EventTrackingTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from certificates.api import get_certificate_url from certificates.api import get_certificate_url
from certificates.models import ExampleCertificateSet, ExampleCertificate, GeneratedCertificate from certificates.models import ExampleCertificateSet, ExampleCertificate, GeneratedCertificate, BadgeAssertion
from certificates.tests.factories import ( from certificates.tests.factories import (
CertificateHtmlViewConfigurationFactory, CertificateHtmlViewConfigurationFactory,
LinkedInAddToProfileConfigurationFactory LinkedInAddToProfileConfigurationFactory,
BadgeAssertionFactory,
) )
from lms import urls
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
...@@ -174,7 +178,7 @@ class UpdateExampleCertificateViewTest(TestCase): ...@@ -174,7 +178,7 @@ class UpdateExampleCertificateViewTest(TestCase):
@attr('shard_1') @attr('shard_1')
class CertificatesViewsTests(ModuleStoreTestCase): class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
""" """
Tests for the manual refund page Tests for the manual refund page
""" """
...@@ -416,3 +420,88 @@ class CertificatesViewsTests(ModuleStoreTestCase): ...@@ -416,3 +420,88 @@ class CertificatesViewsTests(ModuleStoreTestCase):
) )
response = self.client.get(test_url) response = self.client.get(test_url)
self.assertIn("Invalid Certificate", response.content) self.assertIn("Invalid Certificate", response.content)
def test_evidence_event_sent(self):
test_url = get_certificate_url(user_id=self.user.id, course_id=self.course_id) + '?evidence_visit=1'
self.recreate_tracker()
assertion = BadgeAssertion(
user=self.user, course_id=self.course_id, mode='honor',
data={
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json',
}
)
assertion.save()
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
assert_event_matches(
{
'name': 'edx.badges.assertion.evidence_visit',
'data': {
'course_id': 'testorg/run1/refundable_course',
# pylint: disable=no-member
'assertion_id': assertion.id,
'assertion_json_url': 'http://www.example.com/assertion.json',
'assertion_image_url': 'http://www.example.com/image.png',
'user_id': self.user.id,
'issuer': 'http://www.example.com/issuer.json',
'enrollment_mode': 'honor',
},
},
self.get_event()
)
class TrackShareRedirectTest(ModuleStoreTestCase, EventTrackingTestCase):
"""
Verifies the badge image share event is sent out.
"""
def setUp(self):
super(TrackShareRedirectTest, self).setUp()
self.client = Client()
self.course = CourseFactory.create(
org='testorg', number='run1', display_name='trackable course'
)
self.assertion = BadgeAssertionFactory(
user=self.user, course_id=self.course.id, data={
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json',
},
)
# Enabling the feature flag isn't enough to change the URLs-- they're already loaded by this point.
self.old_patterns = urls.urlpatterns
urls.urlpatterns += (urls.BADGE_SHARE_TRACKER_URL,)
def tearDown(self):
super(TrackShareRedirectTest, self).tearDown()
urls.urlpatterns = self.old_patterns
def test_social_event_sent(self):
test_url = '/certificates/badge_share_tracker/{}/social_network/{}/'.format(
unicode(self.course.id),
self.user.username,
)
self.recreate_tracker()
response = self.client.get(test_url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://www.example.com/image.png')
assert_event_matches(
{
'name': 'edx.badges.assertion.shared',
'data': {
'course_id': 'testorg/run1/trackable_course',
'social_network': 'social_network',
# pylint: disable=no-member
'assertion_id': self.assertion.id,
'assertion_json_url': 'http://www.example.com/assertion.json',
'assertion_image_url': 'http://www.example.com/image.png',
'user_id': self.user.id,
'issuer': 'http://www.example.com/issuer.json',
'enrollment_mode': 'honor',
},
},
self.get_event()
)
...@@ -82,3 +82,20 @@ class CertificatesModelTest(ModuleStoreTestCase): ...@@ -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)
"""URL handlers related to certificate handling by LMS""" """URL handlers related to certificate handling by LMS"""
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from django.shortcuts import redirect, get_object_or_404
from opaque_keys.edx.locator import CourseLocator
from eventtracking import tracker
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
import json import json
import logging import logging
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import HttpResponse, Http404, HttpResponseForbidden from django.http import HttpResponse, Http404, HttpResponseForbidden
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -20,10 +22,11 @@ from certificates.models import ( ...@@ -20,10 +22,11 @@ from certificates.models import (
CertificateStatuses, CertificateStatuses,
GeneratedCertificate, GeneratedCertificate,
ExampleCertificate, ExampleCertificate,
CertificateHtmlViewConfiguration CertificateHtmlViewConfiguration,
) BadgeAssertion)
from certificates.queue import XQueueCertInterface from certificates.queue import XQueueCertInterface
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -293,6 +296,11 @@ def _update_certificate_context(context, course, user, user_certificate): ...@@ -293,6 +296,11 @@ def _update_certificate_context(context, course, user, user_certificate):
context['accomplishment_copy_course_org'] = course.org context['accomplishment_copy_course_org'] = course.org
context['accomplishment_copy_course_name'] = course.display_name context['accomplishment_copy_course_name'] = course.display_name
context['logo_alt'] = platform_name context['logo_alt'] = platform_name
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key)
except BadgeAssertion.DoesNotExist:
badge = None
context['badge'] = badge
# Override the defaults with any mode-specific static values # Override the defaults with any mode-specific static values
context['certificate_id_number'] = user_certificate.verify_uuid context['certificate_id_number'] = user_certificate.verify_uuid
...@@ -513,6 +521,29 @@ def render_html_view(request, user_id, course_id): ...@@ -513,6 +521,29 @@ def render_html_view(request, user_id, course_id):
except (InvalidKeyError, CourseDoesNotExist, User.DoesNotExist): except (InvalidKeyError, CourseDoesNotExist, User.DoesNotExist):
return render_to_response(invalid_template_path, context) return render_to_response(invalid_template_path, context)
if 'evidence_visit' in request.GET:
print "Event request found!"
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course_key)
tracker.emit(
'edx.badges.assertion.evidence_visit',
{
'user_id': user.id,
'course_id': unicode(course_key),
'enrollment_mode': badge.mode,
'assertion_id': badge.id,
'assertion_image_url': badge.data['image'],
'assertion_json_url': badge.data['json']['id'],
'issuer': badge.data['issuer'],
}
)
except BadgeAssertion.DoesNotExist:
logger.warn(
"Could not find badge for %s on course %s.",
user.id,
course_key,
)
# Okay, now we have all of the pieces, time to put everything together # Okay, now we have all of the pieces, time to put everything together
# Get the active certificate configuration for this course # Get the active certificate configuration for this course
...@@ -536,3 +567,25 @@ def render_html_view(request, user_id, course_id): ...@@ -536,3 +567,25 @@ def render_html_view(request, user_id, course_id):
context.update(course.cert_html_view_overrides) context.update(course.cert_html_view_overrides)
return render_to_response("certificates/valid.html", context) return render_to_response("certificates/valid.html", context)
@ensure_valid_course_key
def track_share_redirect(request__unused, course_id, network, student_username):
"""
Tracks when a user downloads a badge for sharing.
"""
course_id = CourseLocator.from_string(course_id)
assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id)
tracker.emit(
'edx.badges.assertion.shared', {
'course_id': unicode(course_id),
'social_network': network,
'assertion_id': assertion.id,
'assertion_json_url': assertion.data['json']['id'],
'assertion_image_url': assertion.image_url,
'user_id': assertion.user.id,
'enrollment_mode': assertion.mode,
'issuer': assertion.data['issuer'],
}
)
return redirect(assertion.image_url)
...@@ -12,6 +12,7 @@ from nose.plugins.attrib import attr ...@@ -12,6 +12,7 @@ from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_modes.models import CourseMode from course_modes.models import CourseMode
from track.tests import EventTrackingTestCase
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -34,7 +35,7 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission ...@@ -34,7 +35,7 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission
@attr('shard_1') @attr('shard_1')
class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingTestCase):
""" """
Tests about xblock. Tests about xblock.
""" """
......
...@@ -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
......
...@@ -58,7 +58,6 @@ ...@@ -58,7 +58,6 @@
@extend %depth-card; @extend %depth-card;
} }
// ------------------------------ // ------------------------------
// #IMAGES // #IMAGES
// ------------------------------ // ------------------------------
...@@ -554,3 +553,71 @@ ...@@ -554,3 +553,71 @@
} }
} }
// ------------------------------
// #BADGES MODAL
// ------------------------------
.badges-overlay {
position: fixed;
top: 0;
left: 0;
z-index: z-index(front);
background-color: palette(grayscale, trans); /* dim the background */
width: 100%;
height: 100%;
vertical-align: middle;
.badges-modal {
@extend %copy-large;
box-sizing: content-box;
position: fixed;
top: spacing-vertical(large);
right: 0;
left: 0;
z-index: z-index(very-front);
max-width: 50%;
margin-right: auto;
margin-left: auto;
border-top: rem(10) solid palette(primary, light);
background: palette(grayscale, white);
padding-right: spacing-horizontal(large);
padding-left: spacing-horizontal(large);
overflow-x: hidden;
color: palette(grayscale, dark);
.close {
position: absolute;
right: spacing-horizontal(mid-small);
top: spacing-vertical(small);
font-weight: font-weight(bold);
cursor: pointer;
}
.badges-steps {
display: table;
}
.image-container{
// Lines the image up with the content of the above list.
@include ltr {
@include padding-left(2em);
}
@include rtl {
@include padding-right(1em);
float: right;
}
}
.backpack-logo {
@include float(right);
@include margin-left(spacing-horizontal(small));
}
}
}
.modal-hr {
display: block;
border: none;
background-color: palette(grayscale, light);
height: rem(2);
width: 100%;
}
...@@ -10,10 +10,6 @@ ...@@ -10,10 +10,6 @@
// ------------------------------ // ------------------------------
// #TEMP // #TEMP
// ------------------------------ // ------------------------------
// temporarily hidden banner actions
.action-share-mozillaopenbadges {
display: none !important;
}
// ------------------------------ // ------------------------------
// #DEVELOPERS // #DEVELOPERS
......
$(function () {
'use strict';
$('.action-share-mozillaopenbadges').click(function (event) {
$('.badges-overlay').fadeIn();
event.preventDefault();
});
$('.badges-modal .close').click(function () {
$('.badges-overlay').fadeOut();
});
});
\ No newline at end of file
...@@ -8,11 +8,13 @@ ...@@ -8,11 +8,13 @@
<div class="wrapper-copy-and-actions"> <div class="wrapper-copy-and-actions">
<p class="message-copy copy copy-base emphasized">${accomplishment_banner_congrats}</p> <p class="message-copy copy copy-base emphasized">${accomplishment_banner_congrats}</p>
<div class="message-actions"> <div class="message-actions">
%if badge:
<p class="sr-only">${_("Share on:")}</p> <p class="sr-only">${_("Share on:")}</p>
<button class="action action-share-mozillaopenbadges btn btn-overlay btn-small"> <button class="action action-share-mozillaopenbadges btn btn-overlay btn-small">
<img class="icon icon-mozillaopenbadges" src="/static/certificates/images/ico-mozillaopenbadges.png" alt="Mozilla Open Badges Backpack"> <img class="icon icon-mozillaopenbadges" src="/static/certificates/images/ico-mozillaopenbadges.png" alt="Mozilla Open Badges Backpack">
${_("Add to Mozilla Backpack")} ${_("Add to Mozilla Backpack")}
</button> </button>
%endif
<p class="sr-only">Take this with you:</p> <p class="sr-only">Take this with you:</p>
<button class="action action-print btn btn-overlay btn-small" id="action-print-view"> <button class="action action-print btn btn-overlay btn-small" id="action-print-view">
<i class="icon fa fa-print" aria-hidden="true"></i> <i class="icon fa fa-print" aria-hidden="true"></i>
......
<%namespace name='static' file='../static_content.html'/>
<script src="${static.url('js/lms-base-vendor.js')}"></script>
<script src="${static.url('js/certificates/certificates.js')}"></script>
<div class="badges-overlay" style="display:none;">
<div class="badges-modal">
<div class="close"><i class="fa fa-close" alt="Close"><input type="button" class="sr-only" value="Close"/></i></div>
<h1 class="hd-1 emphasized">Share on Mozilla Backpack</h1>
<p class="explanation">
To share your certificate on Mozilla Backpack, you must first have a Backpack account.
Complete the following steps to add your certificate to Backpack.
</p>
<hr class="modal-hr"/>
<img class="backpack-logo" src="${static.url('certificates/images/backpack-logo.png')}">
<ol class="badges-steps">
<li class="step">Create a <a href="https://backpack.openbadges.org/" target="_blank">Mozilla Backpack</a> account, or log in to your existing account
</li>
<li class="step"><a href="${badge.image_url}" target="_blank">Download this image (right-click, save as)</a> and then <a href="https://backpack.openbadges.org/backpack/add" target="_blank">upload</a> it to your backpack.</li>
</ol>
<div class="image-container">
<img class="badges-backpack-example" src="${static.url('certificates/images/backpack-ui.png')}">
</div>
</div>
</div>
...@@ -39,5 +39,8 @@ course_mode_class = course_mode if course_mode else '' ...@@ -39,5 +39,8 @@ course_mode_class = course_mode if course_mode else ''
</div> </div>
<%include file="_assets-secondary.html" /> <%include file="_assets-secondary.html" />
%if badge:
<%include file="_badges-modal.html" />
%endif
</body> </body>
</html> </html>
...@@ -634,6 +634,17 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): ...@@ -634,6 +634,17 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
'certificates.views.render_html_view', name='cert_html_view'), 'certificates.views.render_html_view', name='cert_html_view'),
) )
BADGE_SHARE_TRACKER_URL = url(
r'^certificates/badge_share_tracker/{}/(?P<network>[^/]+)/(?P<student_username>[^/]+)/$'.format(
settings.COURSE_ID_PATTERN
),
'certificates.views.track_share_redirect',
name='badge_share_tracker'
)
if settings.FEATURES.get('ENABLE_OPENBADGES', False):
urlpatterns += (BADGE_SHARE_TRACKER_URL,)
# XDomain proxy # XDomain proxy
urlpatterns += ( urlpatterns += (
url(r'^xdomain_proxy.html$', 'cors_csrf.views.xdomain_proxy', name='xdomain_proxy'), url(r'^xdomain_proxy.html$', 'cors_csrf.views.xdomain_proxy', name='xdomain_proxy'),
......
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