Commit 81877300 by Jonathan Piacenti

Implement OpenBadge Generation upon Certificate generation through Badgr API

parent bb0180d3
......@@ -59,6 +59,7 @@ jscover.log.*
common/test/data/test_unicode/static/
django-pyfs
test_root/uploads/*.txt
test_root/uploads/badges/*.png
### Installation artifacts
*.egg-info
......
......@@ -669,7 +669,14 @@ class CourseFields(object):
# Ensure that courses imported from XML keep their image
default="images_course_image.jpg"
)
issue_badges = Boolean(
display_name=_("Issue Open Badges"),
help=_(
"Issue Open Badges badges for this course. Badges are generated when certificates are created."
),
scope=Scope.settings,
default=True
)
## Course level Certificate Name overrides.
cert_name_short = String(
help=_(
......
......@@ -162,6 +162,7 @@ class AdvancedSettingsPage(CoursePage):
'info_sidebar_name',
'is_new',
'ispublic',
'issue_badges',
'max_student_enrollments_allowed',
'no_grade',
'display_coursenumber',
......
......@@ -3,8 +3,11 @@ django admin pages for certificates models
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
from certificates.models import CertificateGenerationConfiguration, CertificateHtmlViewConfiguration
from certificates.models import (
CertificateGenerationConfiguration, CertificateHtmlViewConfiguration, BadgeImageConfiguration
)
admin.site.register(CertificateGenerationConfiguration)
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
admin.site.register(BadgeImageConfiguration)
"""
BadgeHandler object-- used to award Badges to users who have completed courses.
"""
import hashlib
import logging
import mimetypes
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext as _
import requests
from django.conf import settings
from django.core.urlresolvers import reverse
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError
from certificates.models import BadgeAssertion, BadgeImageConfiguration
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
LOGGER = logging.getLogger(__name__)
class BadgeHandler(object):
"""
The only properly public method of this class is 'award'. If an alternative object is created for a different
badging service, the other methods don't need to be reproduced.
"""
# Global caching dict
badges = {}
def __init__(self, course_key):
self.course_key = course_key
assert settings.BADGR_API_TOKEN
@lazy
def base_url(self):
"""
Base URL for all API requests.
"""
return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG)
@lazy
def badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return "{}/badges".format(self.base_url)
def badge_url(self, mode):
"""
Get the URL for a course's badge in a given mode.
"""
return "{}/{}".format(self.badge_create_url, self.course_slug(mode))
def assertion_url(self, mode):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self.badge_url(mode))
def course_slug(self, mode):
"""
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
digest = hashlib.sha256(u"{}{}".format(unicode(self.course_key), unicode(mode))).hexdigest()[:7]
base_slug = slugify(unicode(self.course_key) + u'_{}_'.format(mode))[:248]
return base_slug + digest
def log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
u"Encountered an error when contacting the Badgr-Server. Request sent to %s with headers %s.\n"
u"and data values %s\n"
u"Response status was %s.\n%s",
repr(response.request.url), repr(response.request.headers),
repr(data),
response.status_code, response.body
)
raise
def get_headers(self):
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)}
def ensure_badge_created(self, mode):
"""
Verify a badge has been created for this mode of the course, and, if not, create it
"""
if self.course_slug(mode) in BadgeHandler.badges:
return
response = requests.get(self.badge_url(mode), headers=self.get_headers())
if response.status_code != 200:
self.create_badge(mode)
BadgeHandler.badges[self.course_slug(mode)] = True
@staticmethod
def badge_description(course, mode):
"""
Returns a description for the earned badge.
"""
if course.end:
return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format(
start_date=course.start.date(),
end_date=course.end.date(),
course_name=course.display_name,
course_mode=mode,
)
else:
return _(u'Completed the course "{course_name}" ({course_mode})').format(
start_date=course.display_name,
course_mode=mode,
)
def create_badge(self, mode):
"""
Create the badge spec for a course's mode.
"""
course = modulestore().get_course(self.course_key)
image = BadgeImageConfiguration.image_for_mode(mode)
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
"Could not determine content-type of image! Make sure it is a properly named .png file."
)
files = {'image': (image.name, image, content_type)}
about_path = reverse('about_course', kwargs={'course_id': unicode(self.course_key)})
scheme = u"https" if settings.HTTPS == "on" else u"http"
data = {
'name': course.display_name,
'criteria': u'{}://{}{}'.format(scheme, settings.SITE_NAME, about_path),
'slug': self.course_slug(mode),
'description': self.badge_description(course, mode)
}
result = requests.post(self.badge_create_url, headers=self.get_headers(), data=data, files=files)
self.log_if_raised(result, data)
def create_assertion(self, user, mode):
"""
Register an assertion with the Badgr server for a particular user in a particular course mode for
this course.
"""
data = {'email': user.email}
response = requests.post(self.assertion_url(mode), headers=self.get_headers(), data=data)
self.log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(course_id=self.course_key, user=user)
assertion.data = response.json()
assertion.save()
def award(self, user):
"""
Award a user a badge for their work on the course.
"""
mode = CourseEnrollment.objects.get(user=user, course_id=self.course_key).mode
self.ensure_badge_created(mode)
self.create_assertion(user, mode)
......@@ -10,6 +10,7 @@ from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore
from certificates.queue import XQueueCertInterface
from certificates.models import BadgeAssertion
LOGGER = logging.getLogger(__name__)
......@@ -116,6 +117,13 @@ class Command(BaseCommand):
template_file=options['template_file']
)
try:
badge = BadgeAssertion.objects.get(user=student, course_id=course_id)
badge.delete()
LOGGER.info(u"Cleared badge for student %s.", student.id)
except BadgeAssertion.DoesNotExist:
pass
LOGGER.info(
(
u"Added a certificate regeneration task to the XQueue "
......
......@@ -47,23 +47,27 @@ Eligibility:
"""
from datetime import datetime
import json
import logging
import uuid
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils.translation import ugettext_lazy
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields.json import JSONField
from model_utils import Choices
from model_utils.models import TimeStampedModel
from xmodule.modulestore.django import modulestore
from config_models.models import ConfigurationModel
from xmodule_django.models import CourseKeyField, NoneToEmptyManager
from util.milestones_helpers import fulfill_course_milestone
from course_modes.models import CourseMode
LOGGER = logging.getLogger(__name__)
class CertificateStatuses(object):
deleted = 'deleted'
......@@ -331,7 +335,7 @@ class ExampleCertificate(TimeStampedModel):
description = models.CharField(
max_length=255,
help_text=ugettext_lazy(
help_text=_(
u"A human-readable description of the example certificate. "
u"For example, 'verified' or 'honor' to differentiate between "
u"two types of certificates."
......@@ -346,7 +350,7 @@ class ExampleCertificate(TimeStampedModel):
default=_make_uuid,
db_index=True,
unique=True,
help_text=ugettext_lazy(
help_text=_(
u"A unique identifier for the example certificate. "
u"This is used when we receive a response from the queue "
u"to determine which example certificate was processed."
......@@ -357,7 +361,7 @@ class ExampleCertificate(TimeStampedModel):
max_length=255,
default=_make_uuid,
db_index=True,
help_text=ugettext_lazy(
help_text=_(
u"An access key for the example certificate. "
u"This is used when we receive a response from the queue "
u"to validate that the sender is the same entity we asked "
......@@ -368,12 +372,12 @@ class ExampleCertificate(TimeStampedModel):
full_name = models.CharField(
max_length=255,
default=EXAMPLE_FULL_NAME,
help_text=ugettext_lazy(u"The full name that will appear on the certificate.")
help_text=_(u"The full name that will appear on the certificate.")
)
template = models.CharField(
max_length=255,
help_text=ugettext_lazy(u"The template file to use when generating the certificate.")
help_text=_(u"The template file to use when generating the certificate.")
)
# Outputs from certificate generation
......@@ -385,20 +389,20 @@ class ExampleCertificate(TimeStampedModel):
(STATUS_SUCCESS, 'Success'),
(STATUS_ERROR, 'Error')
),
help_text=ugettext_lazy(u"The status of the example certificate.")
help_text=_(u"The status of the example certificate.")
)
error_reason = models.TextField(
null=True,
default=None,
help_text=ugettext_lazy(u"The reason an error occurred during certificate generation.")
help_text=_(u"The reason an error occurred during certificate generation.")
)
download_url = models.CharField(
max_length=255,
null=True,
default=None,
help_text=ugettext_lazy(u"The download URL for the generated certificate.")
help_text=_(u"The download URL for the generated certificate.")
)
def update_status(self, status, error_reason=None, download_url=None):
......@@ -574,3 +578,105 @@ class CertificateHtmlViewConfiguration(ConfigurationModel):
instance = cls.current()
json_data = json.loads(instance.configuration) if instance.enabled else {}
return json_data
class BadgeAssertion(models.Model):
"""
Tracks badges on our side of the badge baking transaction
"""
user = models.ForeignKey(User)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
# Mode a badge was awarded for.
mode = models.CharField(max_length=100)
data = JSONField()
@property
def image_url(self):
"""
Get the image for this assertion.
"""
return self.data['image']
class Meta(object):
"""
Meta information for Django's construction of the model.
"""
unique_together = (('course_id', 'user'),)
def validate_badge_image(image):
"""
Validates that a particular image is small enough, of the right type, and square to be a badge.
"""
if image.width != image.height:
raise ValidationError(_(u"The badge image must be square."))
if not image.size < (250 * 1024):
raise ValidationError(_(u"The badge image file size must be less than 250KB."))
class BadgeImageConfiguration(models.Model):
"""
Contains the configuration for badges for a specific mode. The mode
"""
mode = models.CharField(
max_length=125,
help_text=_(u'The course mode for this badge image. For example, "verified" or "honor".'),
unique=True,
)
icon = models.ImageField(
# Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
help_text=_(
u"Badge images must be square PNG files. The file size should be under 250KB."
),
upload_to='badges',
validators=[validate_badge_image]
)
default = models.BooleanField(
help_text=_(
u"Set this value to True if you want this image to be the default image for any course modes "
u"that do not have a specified badge image. You can have only one default image."
)
)
def clean(self):
"""
Make sure there's not more than one default.
"""
# pylint: disable=no-member
if self.default and BadgeImageConfiguration.objects.filter(default=True).exclude(id=self.id):
raise ValidationError(_(u"There can be only one default image."))
@classmethod
def image_for_mode(cls, mode):
"""
Get the image for a particular mode.
"""
try:
return cls.objects.get(mode=mode).icon
except cls.DoesNotExist:
# Fall back to default, if there is one.
return cls.objects.get(default=True).icon
@receiver(post_save, sender=GeneratedCertificate)
#pylint: disable=unused-argument
def create_badge(sender, instance, **kwargs):
"""
Standard signal hook to create badges when a certificate has been generated.
"""
if not settings.FEATURES.get('ENABLE_OPENBADGES', False):
return
if not modulestore().get_course(instance.course_id).issue_badges:
LOGGER.info("Course is not configured to issue badges.")
return
if BadgeAssertion.objects.filter(user=instance.user, course_id=instance.course_id):
LOGGER.info("Badge already exists for this user on this course.")
# Badge already exists. Skip.
return
# Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this
# by making sure it only gets run on the callback during normal workflow.
if not instance.status == CertificateStatuses.downloadable:
return
from .badge_handler import BadgeHandler
handler = BadgeHandler(instance.course_id)
handler.award(instance.user)
from factory.django import DjangoModelFactory
# Factories are self documenting
# pylint: disable=missing-docstring
from factory.django import DjangoModelFactory, ImageField
from student.models import LinkedInAddToProfileConfiguration
from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion,
BadgeImageConfiguration,
)
# Factories are self documenting
# pylint: disable=missing-docstring
class GeneratedCertificateFactory(DjangoModelFactory):
FACTORY_FOR = GeneratedCertificate
......@@ -27,6 +28,20 @@ class CertificateWhitelistFactory(DjangoModelFactory):
whitelist = True
class BadgeAssertionFactory(DjangoModelFactory):
FACTORY_FOR = BadgeAssertion
mode = 'honor'
class BadgeImageConfigurationFactory(DjangoModelFactory):
FACTORY_FOR = BadgeImageConfiguration
mode = 'honor'
icon = ImageField(color='blue', height=50, width=50, filename='test.png', format='PNG')
class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
FACTORY_FOR = CertificateHtmlViewConfiguration
......
"""
Tests for the BadgeHandler, which communicates with the Badgr Server.
"""
from datetime import datetime
from django.test.utils import override_settings
from django.db.models.fields.files import ImageFieldFile
from lazy.lazy import lazy
from mock import patch, Mock, call
from certificates.models import BadgeAssertion, BadgeImageConfiguration
from track.tests import EventTrackingTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from certificates.badge_handler import BadgeHandler
from certificates.tests.factories import BadgeImageConfigurationFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
BADGR_SETTINGS = {
'BADGR_API_TOKEN': '12345',
'BADGR_BASE_URL': 'https://example.com',
'BADGR_ISSUER_SLUG': 'test-issuer',
}
@override_settings(**BADGR_SETTINGS)
class BadgeHandlerTestCase(ModuleStoreTestCase, EventTrackingTestCase):
"""
Tests the BadgeHandler object
"""
def setUp(self):
"""
Create a course and user to test with.
"""
super(BadgeHandlerTestCase, self).setUp()
# Need key to be deterministic to test slugs.
self.course = CourseFactory.create(
org='edX', course='course_test', run='test_run', display_name='Badged',
start=datetime(year=2015, month=5, day=19),
end=datetime(year=2015, month=5, day=20)
)
self.user = UserFactory.create(email='example@example.com')
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.location.course_key, mode='honor')
# Need for force empty this dict on each run.
BadgeHandler.badges = {}
BadgeImageConfigurationFactory()
@lazy
def handler(self):
"""
Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings
overrides aren't in place.
"""
return BadgeHandler(self.course.location.course_key)
def test_urls(self):
"""
Make sure the handler generates the correct URLs for different API tasks.
"""
self.assertEqual(self.handler.base_url, 'https://example.com/v1/issuer/issuers/test-issuer')
self.assertEqual(self.handler.badge_create_url, 'https://example.com/v1/issuer/issuers/test-issuer/badges')
self.assertEqual(
self.handler.badge_url('honor'),
'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b'
)
self.assertEqual(
self.handler.assertion_url('honor'),
'https://example.com/v1/issuer/issuers/test-issuer/badges/edxcourse_testtest_run_honor_fc5519b/assertions'
)
def check_headers(self, headers):
"""
Verify the a headers dict from a requests call matches the proper auth info.
"""
self.assertEqual(headers, {'Authorization': 'Token 12345'})
def test_slug(self):
"""
Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause
the handler to lose track of all badges it made in the past.
"""
self.assertEqual(
self.handler.course_slug('honor'),
'edxcourse_testtest_run_honor_fc5519b'
)
self.assertEqual(
self.handler.course_slug('verified'),
'edxcourse_testtest_run_verified_a199ec0'
)
def test_get_headers(self):
"""
Check to make sure the handler generates appropriate HTTP headers.
"""
self.check_headers(self.handler.get_headers())
@patch('requests.post')
def test_create_badge(self, post):
"""
Verify badge spec creation works.
"""
self.handler.create_badge('honor')
args, kwargs = post.call_args
self.assertEqual(args[0], 'https://example.com/v1/issuer/issuers/test-issuer/badges')
self.assertEqual(kwargs['files']['image'][0], BadgeImageConfiguration.objects.get(mode='honor').icon.name)
self.assertIsInstance(kwargs['files']['image'][1], ImageFieldFile)
self.assertEqual(kwargs['files']['image'][2], 'image/png')
self.check_headers(kwargs['headers'])
self.assertEqual(
kwargs['data'],
{
'name': 'Badged',
'slug': 'edxcourse_testtest_run_honor_fc5519b',
'criteria': 'https://edx.org/courses/edX/course_test/test_run/about',
'description': 'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)',
}
)
def test_ensure_badge_created_cache(self):
"""
Make sure ensure_badge_created doesn't call create_badge if we know the badge is already there.
"""
BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'] = True
self.handler.create_badge = Mock()
self.handler.ensure_badge_created('honor')
self.assertFalse(self.handler.create_badge.called)
@patch('requests.get')
def test_ensure_badge_created_checks(self, get):
response = Mock()
response.status_code = 200
get.return_value = response
self.assertNotIn('edxcourse_testtest_run_honor_fc5519b', BadgeHandler.badges)
self.handler.create_badge = Mock()
self.handler.ensure_badge_created('honor')
self.assertTrue(get.called)
args, kwargs = get.call_args
self.assertEqual(
args[0],
'https://example.com/v1/issuer/issuers/test-issuer/badges/'
'edxcourse_testtest_run_honor_fc5519b'
)
self.check_headers(kwargs['headers'])
self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'])
self.assertFalse(self.handler.create_badge.called)
@patch('requests.get')
def test_ensure_badge_created_creates(self, get):
response = Mock()
response.status_code = 404
get.return_value = response
self.assertNotIn('edxcourse_testtest_run_honor_fc5519b', BadgeHandler.badges)
self.handler.create_badge = Mock()
self.handler.ensure_badge_created('honor')
self.assertTrue(self.handler.create_badge.called)
self.assertEqual(self.handler.create_badge.call_args, call('honor'))
self.assertTrue(BadgeHandler.badges['edxcourse_testtest_run_honor_fc5519b'])
@patch('requests.post')
def test_create_assertion(self, post):
result = {
'json': {'id': 'http://www.example.com/example'},
'image': 'http://www.example.com/example.png',
'slug': 'test_assertion_slug',
'issuer': 'https://example.com/v1/issuer/issuers/test-issuer',
}
response = Mock()
response.json.return_value = result
post.return_value = response
self.recreate_tracker()
self.handler.create_assertion(self.user, 'honor')
args, kwargs = post.call_args
self.assertEqual(
args[0],
'https://example.com/v1/issuer/issuers/test-issuer/badges/'
'edxcourse_testtest_run_honor_fc5519b/assertions'
)
self.check_headers(kwargs['headers'])
self.assertEqual(kwargs['data'], {'email': 'example@example.com'})
badge = BadgeAssertion.objects.get(user=self.user, course_id=self.course.location.course_key)
self.assertEqual(badge.data, result)
self.assertEqual(badge.image_url, 'http://www.example.com/example.png')
......@@ -2,28 +2,64 @@
import ddt
from django.core.management.base import CommandError
from nose.plugins.attrib import attr
from django.test.utils import override_settings
from mock import patch
from opaque_keys.edx.locator import CourseLocator
from certificates.tests.factories import BadgeAssertionFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.management.commands import resubmit_error_certificates
from certificates.models import GeneratedCertificate, CertificateStatuses
from certificates.management.commands import resubmit_error_certificates, regenerate_user
from certificates.models import GeneratedCertificate, CertificateStatuses, BadgeAssertion
@attr('shard_1')
@ddt.ddt
class ResubmitErrorCertificatesTest(ModuleStoreTestCase):
"""Tests for the resubmit_error_certificates management command. """
class CertificateManagementTest(ModuleStoreTestCase):
"""
Base test class for Certificate Management command tests.
"""
# Override with the command module you wish to test.
command = resubmit_error_certificates
def setUp(self):
super(ResubmitErrorCertificatesTest, self).setUp()
super(CertificateManagementTest, self).setUp()
self.user = UserFactory.create()
self.courses = [
CourseFactory.create()
for __ in range(3)
]
def _create_cert(self, course_key, user, status):
"""Create a certificate entry. """
# Enroll the user in the course
CourseEnrollmentFactory.create(
user=user,
course_id=course_key
)
# Create the certificate
GeneratedCertificate.objects.create(
user=user,
course_id=course_key,
status=status
)
def _run_command(self, *args, **kwargs):
"""Run the management command to generate a fake cert. """
command = self.command.Command()
return command.handle(*args, **kwargs)
def _assert_cert_status(self, course_key, user, expected_status):
"""Check the status of a certificate. """
cert = GeneratedCertificate.objects.get(user=user, course_id=course_key)
self.assertEqual(cert.status, expected_status)
@attr('shard_1')
@ddt.ddt
class ResubmitErrorCertificatesTest(CertificateManagementTest):
"""Tests for the resubmit_error_certificates management command. """
def test_resubmit_error_certificate(self):
# Create a certificate with status 'error'
self._create_cert(self.courses[0].id, self.user, CertificateStatuses.error)
......@@ -105,27 +141,39 @@ class ResubmitErrorCertificatesTest(ModuleStoreTestCase):
# since the course doesn't actually exist.
self._assert_cert_status(phantom_course, self.user, CertificateStatuses.error)
def _create_cert(self, course_key, user, status):
"""Create a certificate entry. """
# Enroll the user in the course
CourseEnrollmentFactory.create(
user=user,
course_id=course_key
)
# 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 = resubmit_error_certificates.Command()
return command.handle(*args, **kwargs)
@attr('shard_1')
class RegenerateCertificatesTest(CertificateManagementTest):
"""
Tests for regenerating certificates.
"""
command = regenerate_user
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)
def setUp(self):
"""
We just need one course here.
"""
super(RegenerateCertificatesTest, self).setUp()
self.course = self.courses[0]
@override_settings(CERT_QUEUE='test-queue')
@patch('certificates.management.commands.regenerate_user.XQueueCertInterface', spec=True)
def test_clear_badge(self, xqueue):
"""
Given that I have a user with a badge
If I run regeneration for a user
Then certificate generation will be requested
And the badge will be deleted
"""
key = self.course.location.course_key
BadgeAssertionFactory(user=self.user, course_id=key, data={})
self._create_cert(key, self.user, CertificateStatuses.downloadable)
self.assertTrue(BadgeAssertion.objects.filter(user=self.user, course_id=key))
self._run_command(
username=self.user.email, course=unicode(key), noop=False, insecure=False, template_file=None,
grade_value=None
)
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. """
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.images import ImageFile
from django.test import TestCase
from django.test.utils import override_settings
from nose.plugins.attrib import attr
# pylint: disable=no-name-in-module
from path import path
from opaque_keys.edx.locator import CourseLocator
from certificates.models import (
ExampleCertificate,
ExampleCertificateSet,
CertificateHtmlViewConfiguration
)
CertificateHtmlViewConfiguration,
BadgeImageConfiguration)
FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy()
FEATURES_INVALID_FILE_PATH['CERTS_HTML_VIEW_CONFIG_PATH'] = 'invalid/path/to/config.json'
# pylint: disable=invalid-name
TEST_DIR = path(__file__).dirname()
TEST_DATA_DIR = 'common/test/data/'
PLATFORM_ROOT = TEST_DIR.parent.parent.parent.parent
TEST_DATA_ROOT = PLATFORM_ROOT / TEST_DATA_DIR
@attr('shard_1')
class ExampleCertificateTest(TestCase):
......@@ -151,3 +160,52 @@ class CertificateHtmlViewConfigurationTest(TestCase):
self.config.configuration = ''
self.config.save()
self.assertEquals(self.config.get_config(), {})
@attr('shard_1')
class BadgeImageConfigurationTest(TestCase):
"""
Test the validation features of BadgeImageConfiguration.
"""
def get_image(self, name):
"""
Get one of the test images from the test data directory.
"""
return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png'))
def create_clean(self, file_obj):
"""
Shortcut to create a BadgeImageConfiguration with a specific file.
"""
BadgeImageConfiguration(mode='honor', icon=file_obj).full_clean()
def test_good_image(self):
"""
Verify that saving a valid badge image is no problem.
"""
good = self.get_image('good')
BadgeImageConfiguration(mode='honor', icon=good).full_clean()
def test_unbalanced_image(self):
"""
Verify that setting an image with an uneven width and height raises an error.
"""
unbalanced = ImageFile(self.get_image('unbalanced'))
self.assertRaises(ValidationError, self.create_clean, unbalanced)
def test_large_image(self):
"""
Verify that setting an image that is too big raises an error.
"""
large = self.get_image('large')
self.assertRaises(ValidationError, self.create_clean, large)
def test_no_double_default(self):
"""
Verify that creating two configurations as default is not permitted.
"""
BadgeImageConfiguration(mode='test', icon=self.get_image('good'), default=True).save()
self.assertRaises(
ValidationError,
BadgeImageConfiguration(mode='test2', icon=self.get_image('good'), default=True).full_clean
)
......@@ -82,3 +82,20 @@ class CertificatesModelTest(ModuleStoreTestCase):
completed_milestones = milestones_achieved_by_user(student, unicode(pre_requisite_course.id))
self.assertEqual(len(completed_milestones), 1)
self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id))
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@patch('certificates.badge_handler.BadgeHandler', spec=True)
def test_badge_callback(self, handler):
student = UserFactory()
course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True)
cert = GeneratedCertificateFactory.create(
user=student,
course_id=course.id,
status=CertificateStatuses.generating,
mode='verified'
)
# Check return value since class instance will be stored there.
self.assertFalse(handler.return_value.award.called)
cert.status = CertificateStatuses.downloadable
cert.save()
self.assertTrue(handler.return_value.award.called)
......@@ -262,6 +262,11 @@ ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL")
FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL")
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
# Badgr API
BADGR_API_TOKEN = ENV_TOKENS.get('BADGR_API_TOKEN', BADGR_API_TOKEN)
BADGR_BASE_URL = ENV_TOKENS.get('BADGR_BASE_URL', BADGR_BASE_URL)
BADGR_ISSUER_SLUG = ENV_TOKENS.get('BADGR_ISSUER_SLUG', BADGR_ISSUER_SLUG)
# git repo loading environment
GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos')
GIT_IMPORT_STATIC = ENV_TOKENS.get('GIT_IMPORT_STATIC', True)
......
......@@ -399,6 +399,9 @@ FEATURES = {
# How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
# Enable OpenBadge support. See the BADGR_* settings later in this file.
'ENABLE_OPENBADGES': False,
}
# Ignore static asset files on import which match this pattern
......@@ -2024,6 +2027,13 @@ REGISTRATION_EXTRA_FIELDS = {
CERT_NAME_SHORT = "Certificate"
CERT_NAME_LONG = "Certificate of Achievement"
#################### Badgr OpenBadges generation #######################
# Be sure to set up images for course modes using the BadgeImageConfiguration model in the certificates app.
BADGR_API_TOKEN = None
# Do not add the trailing slash here.
BADGR_BASE_URL = "http://localhost:8005"
BADGR_ISSUER_SLUG = "example-issuer"
###################### Grade Downloads ######################
GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
......
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