""" 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)