Files
edx-platform/lms/djangoapps/badges/backends/badgr.py
2021-02-25 18:15:27 +05:00

184 lines
6.6 KiB
Python

"""
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 eventtracking import tracker
from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
from requests.packages.urllib3.exceptions import HTTPError # lint-amnesty, pylint: disable=import-error
from lms.djangoapps.badges.backends.base import BadgeBackend
from lms.djangoapps.badges.models import BadgeAssertion
MAX_SLUG_LENGTH = 255
LOGGER = logging.getLogger(__name__)
class BadgrBackend(BadgeBackend):
"""
Backend for Badgr-Server by Concentric Sky. http://info.badgr.io/
"""
badges = []
def __init__(self):
super().__init__()
if not settings.BADGR_API_TOKEN:
raise ImproperlyConfigured("BADGR_API_TOKEN not set.")
@lazy
def _base_url(self):
"""
Base URL for all API requests.
"""
return f"{settings.BADGR_BASE_URL}/v1/issuer/issuers/{settings.BADGR_ISSUER_SLUG}"
@lazy
def _badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return f"{self._base_url}/badges"
def _badge_url(self, slug):
"""
Get the URL for a course's badge in a given mode.
"""
return f"{self._badge_create_url}/{slug}"
def _assertion_url(self, slug):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self._badge_url(slug))
def _slugify(self, badge_class):
"""
Get a compatible badge slug from the specification.
"""
slug = badge_class.issuing_component + badge_class.slug
if badge_class.issuing_component and badge_class.course_id:
# Make this unique to the course, and down to 64 characters.
# We don't do this to badges without issuing_component set for backwards compatibility.
slug = hashlib.sha256((slug + str(badge_class.course_id)).encode('utf-8')).hexdigest()
if len(slug) > MAX_SLUG_LENGTH:
# Will be 64 characters.
slug = hashlib.sha256(slug).hexdigest()
return slug
def _log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
"Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n"
"and data values %r\n"
"Response status was %s.\n%s",
response.request.url, response.request.headers,
data,
response.status_code, response.content
)
raise
def _create_badge(self, badge_class):
"""
Create the badge class on Badgr.
"""
image = badge_class.image
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
"Could not determine content-type of image! Make sure it is a properly named .png file. "
"Filename was: {}".format(image.name)
)
files = {'image': (image.name, image, content_type)}
data = {
'name': badge_class.display_name,
'criteria': badge_class.criteria,
'slug': self._slugify(badge_class),
'description': badge_class.description,
}
result = requests.post(
self._badge_create_url, headers=self._get_headers(), data=data, files=files,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(result, data)
def _send_assertion_created_event(self, user, assertion):
"""
Send an analytics event to record the creation of a badge assertion.
"""
tracker.emit(
'edx.badge.assertion.created', {
'user_id': user.id,
'badge_slug': assertion.badge_class.slug,
'badge_name': assertion.badge_class.display_name,
'issuing_component': assertion.badge_class.issuing_component,
'course_id': str(assertion.badge_class.course_id),
'enrollment_mode': assertion.badge_class.mode,
'assertion_id': assertion.id,
'assertion_image_url': assertion.image_url,
'assertion_json_url': assertion.assertion_url,
'issuer': assertion.data.get('issuer'),
}
)
def _create_assertion(self, badge_class, user, evidence_url):
"""
Register an assertion with the Badgr server for a particular user for a specific class.
"""
data = {
'email': user.email,
'evidence': evidence_url,
}
response = requests.post(
self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class)
assertion.data = response.json()
assertion.backend = 'BadgrBackend'
assertion.image_url = assertion.data['image']
assertion.assertion_url = assertion.data['json']['id']
assertion.save()
self._send_assertion_created_event(user, assertion)
return assertion
@staticmethod
def _get_headers():
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': f'Token {settings.BADGR_API_TOKEN}'}
def _ensure_badge_created(self, badge_class):
"""
Verify a badge has been created for this badge class, and create it if not.
"""
slug = self._slugify(badge_class)
if slug in BadgrBackend.badges:
return
response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
if response.status_code != 200:
self._create_badge(badge_class)
BadgrBackend.badges.append(slug)
def award(self, badge_class, user, evidence_url=None):
"""
Make sure the badge class has been created on the backend, and then award the badge class to the user.
"""
self._ensure_badge_created(badge_class)
return self._create_assertion(badge_class, user, evidence_url)