Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 10 additions & 19 deletions lms/djangoapps/badges/backends/badgr.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""
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 django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError

Expand Down Expand Up @@ -57,20 +58,6 @@ def _assertion_url(self, slug):
"""
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.
Expand Down Expand Up @@ -102,10 +89,14 @@ def _create_badge(self, badge_class):
u"Filename was: {}".format(image.name)
)
files = {'image': (image.name, image, content_type)}
try: # TODO: eventually we should pass both
URLValidator(badge_class.criteria)
criteria_type = 'criteria_url'
except ValidationError:
criteria_type = 'criteria_text'
data = {
'name': badge_class.display_name,
'criteria': badge_class.criteria,
'slug': self._slugify(badge_class),
criteria_type: badge_class.criteria,
'description': badge_class.description,
}
result = requests.post(
Expand Down Expand Up @@ -142,7 +133,7 @@ def _create_assertion(self, badge_class, user, evidence_url):
'evidence': evidence_url,
}
response = requests.post(
self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data,
self._assertion_url(badge_class.slug), headers=self._get_headers(), data=data,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(response, data)
Expand All @@ -166,7 +157,7 @@ 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)
slug = badge_class.slug
if slug in BadgrBackend.badges:
return
response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
Expand Down
19 changes: 0 additions & 19 deletions lms/djangoapps/badges/events/course_complete.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"""
Helper functions for the course complete event that was originally included with the Badging MVP.
"""
import hashlib
import logging

from django.core.urlresolvers import reverse
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext_lazy as _

from badges.models import CourseCompleteImageConfiguration, BadgeClass, BadgeAssertion
Expand All @@ -19,21 +17,6 @@
# 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.
Expand Down Expand Up @@ -85,8 +68,6 @@ def get_completion_badge(course_id, user):
if not course.issue_badges:
return None
return BadgeClass.get_badge_class(
slug=course_slug(course_id, mode),
issuing_component='',
criteria=criteria(course_id),
description=badge_description(course, mode),
course_id=course_id,
Expand Down
24 changes: 19 additions & 5 deletions lms/djangoapps/badges/events/course_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from badges.models import CourseEventBadgesConfiguration, BadgeClass
from badges.utils import requires_badges_enabled
from badges.events.course_complete import evidence_url


def award_badge(config, count, user):
Expand All @@ -23,7 +24,7 @@ def award_badge(config, count, user):
if not slug:
return
badge_class = BadgeClass.get_badge_class(
slug=slug, issuing_component='openedx__course', create=False,
slug=slug, create=False,
)
if not badge_class:
return
Expand Down Expand Up @@ -71,11 +72,24 @@ def course_group_check(user, course_key):
course_id__in=keys,
)
if len(certs) == len(keys):
awards.append(slug)
# course_complete Assertions are not working correctly
# yet with Badgr.io, while course group is working.
# so we use course groups with a single course,
# in which case we can provide an evidence URL
# to the HTML cert for the one coursee
if len(keys) == 1:
evidence = evidence_url(user.id, course_key)
awards.append((slug, evidence))
else:
awards.append(slug)

for slug in awards:
for award in awards:
badge_class = BadgeClass.get_badge_class(
slug=slug, issuing_component='openedx__course', create=False,
slug=award[0], create=False,
)
if badge_class and not badge_class.get_for_user(user):
badge_class.award(user)
if award[1]:
badge_class.award(user, evidence_url=award[1])
else:
badge_class.award(user)

38 changes: 20 additions & 18 deletions lms/djangoapps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,43 +48,47 @@ class BadgeClass(models.Model):
"""
Specifies a badge class to be registered with a backend.
"""
slug = models.SlugField(max_length=255, validators=[validate_lowercase])
slug = models.SlugField(max_length=255, unique=True)
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.
criteria = models.TextField() # TODO: Badgr and Open Badges spec can take both text and url criteria
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
return u"<Badge '{slug}' for '{issuing_component}', {course_id} {mode}>".format(
slug=self.slug, issuing_component=self.issuing_component,
course_id = unicode(self.course_id), mode=self.mode
)

@classmethod
def get_badge_class(
cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None,
cls, slug=None, issuing_component=None, display_name=None, description=None, criteria=None, image_file_handle=None,
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.
Looks up a badge class by its slug, or combination of mode 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.

The expectation is that an XBlock or platform developer should not need to concern themselves with whether
or not a badge class has already been created, but should just feed all requirements to this function
and it will 'do the right thing'. It should be the exception, rather than the common case, that a badge class
would need to be looked up without also being created were it missing.
"""
slug = slug.lower()
issuing_component = issuing_component.lower()
if course_id and not modulestore().get_course(course_id).issue_badges:
raise CourseBadgesDisabledError("This course does not have badges enabled.")
if not course_id:
course_id = CourseKeyField.Empty
try:
return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id)
if slug:
return cls.objects.get(slug=slug)
else:
if mode:
return cls.objects.get(mode=mode, course_id=course_id)
else: # allow setting a BadgeClass with no mode, can be used for all modes
return cls.objects.get(course_id=course_id)
except cls.DoesNotExist:
if not create:
return None
Expand Down Expand Up @@ -123,17 +127,15 @@ def award(self, user, evidence_url=None):
"""
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)
# def save(self, **kwargs):
# #"""
# # Slugs must always be lowercase.
# #"""
# super(BadgeClass, self).save(**kwargs)

class Meta(object):
app_label = "badges"
unique_together = (('slug', 'issuing_component', 'course_id'),)
unique_together = (('mode', 'course_id'),)
verbose_name_plural = "Badge Classes"


Expand Down