Badgr integration updates (#27181)
* Badgr integration fix Badges are no longer created on the Badgr side with a given 'slug'. Instead, a slug(v1) or an entityId(v2) will be generated for each badge on the Badgr side and we will need to use that value to check if a certain badge matching a BadgeClass on our side exists and/or to create assertions for it. This commit introduces a new field to the badgeclass: 'badgr_server_slug' by cherry-picking the following commit from 3309aab2a2eb00d28c5ca3d3145c8dddb15e6159 - TTK-18543: fix Badgr Server connection (https://github.com/teltek/edx-platform/pull/46) This commit also modifies the cherry-picked commit by making the newly added field optional since the BadgeClass is not neccessarily always used with the Badgr backend. Co-authored-by: mrey <mrey@teltek.es> * Implement OAuth2 tokens flow for BadgrBackend * Use Badgr v2 API Co-authored-by: mrey <mrey@teltek.es>
This commit is contained in:
@@ -3,17 +3,23 @@ Badge Awarding backend for Badgr-Server.
|
||||
"""
|
||||
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
import requests
|
||||
from cryptography.fernet import Fernet
|
||||
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 edx_django_utils.cache import TieredCache
|
||||
|
||||
from lms.djangoapps.badges.backends.base import BadgeBackend
|
||||
from lms.djangoapps.badges.models import BadgeAssertion
|
||||
|
||||
@@ -29,28 +35,37 @@ class BadgrBackend(BadgeBackend):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
if not settings.BADGR_API_TOKEN:
|
||||
raise ImproperlyConfigured("BADGR_API_TOKEN not set.")
|
||||
if None in (settings.BADGR_USERNAME,
|
||||
settings.BADGR_PASSWORD,
|
||||
settings.BADGR_TOKENS_CACHE_KEY,
|
||||
settings.BADGR_ISSUER_SLUG,
|
||||
settings.BADGR_BASE_URL):
|
||||
error_msg = (
|
||||
"One or more of the required settings are not defined. "
|
||||
"Required settings: BADGR_USERNAME, BADGR_PASSWORD, "
|
||||
"BADGR_TOKENS_CACHE_KEY, BADGR_ISSUER_SLUG, BADGR_BASE_URL.")
|
||||
LOGGER.error(error_msg)
|
||||
raise ImproperlyConfigured(error_msg)
|
||||
|
||||
@lazy
|
||||
def _base_url(self):
|
||||
"""
|
||||
Base URL for all API requests.
|
||||
Base URL for API requests that contain the issuer slug.
|
||||
"""
|
||||
return f"{settings.BADGR_BASE_URL}/v1/issuer/issuers/{settings.BADGR_ISSUER_SLUG}"
|
||||
return f"{settings.BADGR_BASE_URL}/v2/issuers/{settings.BADGR_ISSUER_SLUG}"
|
||||
|
||||
@lazy
|
||||
def _badge_create_url(self):
|
||||
"""
|
||||
URL for generating a new Badge specification
|
||||
"""
|
||||
return f"{self._base_url}/badges"
|
||||
return f"{self._base_url}/badgeclasses"
|
||||
|
||||
def _badge_url(self, slug):
|
||||
"""
|
||||
Get the URL for a course's badge in a given mode.
|
||||
"""
|
||||
return f"{self._badge_create_url}/{slug}"
|
||||
return f"{settings.BADGR_BASE_URL}/v2/badgeclasses/{slug}"
|
||||
|
||||
def _assertion_url(self, slug):
|
||||
"""
|
||||
@@ -102,18 +117,28 @@ class BadgrBackend(BadgeBackend):
|
||||
"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)
|
||||
with open(image.path, 'rb') as image_file:
|
||||
files = {'image': (image.name, image_file, content_type)}
|
||||
data = {
|
||||
'name': badge_class.display_name,
|
||||
'criteriaUrl': badge_class.criteria,
|
||||
'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)
|
||||
try:
|
||||
result_json = result.json()
|
||||
badgr_badge_class = result_json['result'][0]
|
||||
badgr_server_slug = badgr_badge_class.get('entityId')
|
||||
badge_class.badgr_server_slug = badgr_server_slug
|
||||
badge_class.save()
|
||||
except Exception as excep: # pylint: disable=broad-except
|
||||
LOGGER.error(
|
||||
'Error on saving Badgr Server Slug of badge_class slug '
|
||||
'"{0}" with response json "{1}" : {2}'.format(
|
||||
badge_class.slug, result.json(), excep))
|
||||
|
||||
def _send_assertion_created_event(self, user, assertion):
|
||||
"""
|
||||
@@ -123,6 +148,7 @@ class BadgrBackend(BadgeBackend):
|
||||
'edx.badge.assertion.created', {
|
||||
'user_id': user.id,
|
||||
'badge_slug': assertion.badge_class.slug,
|
||||
'badge_badgr_server_slug': assertion.badge_class.badgr_server_slug,
|
||||
'badge_name': assertion.badge_class.display_name,
|
||||
'issuing_component': assertion.badge_class.issuing_component,
|
||||
'course_id': str(assertion.badge_class.course_id),
|
||||
@@ -139,11 +165,20 @@ class BadgrBackend(BadgeBackend):
|
||||
Register an assertion with the Badgr server for a particular user for a specific class.
|
||||
"""
|
||||
data = {
|
||||
'email': user.email,
|
||||
'evidence': evidence_url,
|
||||
"recipient": {
|
||||
"identity": user.email,
|
||||
"type": "email"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"url": evidence_url
|
||||
}
|
||||
]
|
||||
}
|
||||
response = requests.post(
|
||||
self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data,
|
||||
self._assertion_url(badge_class.badgr_server_slug),
|
||||
headers=self._get_headers(),
|
||||
json=data,
|
||||
timeout=settings.BADGR_TIMEOUT
|
||||
)
|
||||
self._log_if_raised(response, data)
|
||||
@@ -157,17 +192,113 @@ class BadgrBackend(BadgeBackend):
|
||||
return assertion
|
||||
|
||||
@staticmethod
|
||||
def _get_headers():
|
||||
def _fernet_setup():
|
||||
"""
|
||||
Set up the Fernet class for encrypting/decrypting tokens.
|
||||
Fernet keys must always be URL-safe base64 encoded 32-byte binary
|
||||
strings. Use the SECRET_KEY for creating the encryption key.
|
||||
"""
|
||||
fernet_key = base64.urlsafe_b64encode(
|
||||
settings.SECRET_KEY.ljust(64).encode('utf-8')[:32]
|
||||
)
|
||||
return Fernet(fernet_key)
|
||||
|
||||
def _encrypt_token(self, token):
|
||||
"""
|
||||
Encrypt a token
|
||||
"""
|
||||
fernet = self._fernet_setup()
|
||||
return fernet.encrypt(token.encode('utf-8'))
|
||||
|
||||
def _decrypt_token(self, token):
|
||||
"""
|
||||
Decrypt a token
|
||||
"""
|
||||
fernet = self._fernet_setup()
|
||||
return fernet.decrypt(token).decode()
|
||||
|
||||
def _get_and_cache_oauth_tokens(self, refresh_token=None):
|
||||
"""
|
||||
Get or renew OAuth tokens. If a refresh_token is provided,
|
||||
use it to renew tokens, otherwise create new ones.
|
||||
Once tokens are created/renewed, encrypt the values and cache them.
|
||||
"""
|
||||
data = {
|
||||
'username': settings.BADGR_USERNAME,
|
||||
'password': settings.BADGR_PASSWORD,
|
||||
}
|
||||
if refresh_token:
|
||||
data = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
|
||||
oauth_url = "{}/o/token".format(settings.BADGR_BASE_URL)
|
||||
|
||||
response = requests.post(
|
||||
oauth_url, data=data, timeout=settings.BADGR_TIMEOUT
|
||||
)
|
||||
self._log_if_raised(response, data)
|
||||
try:
|
||||
data = response.json()
|
||||
result = {
|
||||
'access_token': self._encrypt_token(data['access_token']),
|
||||
'refresh_token': self._encrypt_token(data['refresh_token']),
|
||||
'expires_at': datetime.datetime.utcnow() + datetime.timedelta(
|
||||
seconds=data['expires_in'])
|
||||
}
|
||||
# The refresh_token is long-lived, we want to be able to retrieve
|
||||
# it from cache as long as possible.
|
||||
# Set the cache timeout to None so the cache key never expires
|
||||
# (https://docs.djangoproject.com/en/2.2/topics/cache/#cache-arguments)
|
||||
TieredCache.set_all_tiers(
|
||||
settings.BADGR_TOKENS_CACHE_KEY, result, None)
|
||||
return result
|
||||
except (KeyError, json.decoder.JSONDecodeError) as json_error:
|
||||
raise requests.RequestException(response=response) from json_error
|
||||
|
||||
def _get_access_token(self):
|
||||
"""
|
||||
Get an access token from cache if one is present and valid. If a
|
||||
token is cached but expired, renew it. If all fails or a token has
|
||||
not yet been cached, create a new one.
|
||||
"""
|
||||
tokens = {}
|
||||
cached_response = TieredCache.get_cached_response(
|
||||
settings.BADGR_TOKENS_CACHE_KEY)
|
||||
if cached_response.is_found:
|
||||
cached_tokens = cached_response.value
|
||||
# add a 5 seconds buffer to the cutoff timestamp to make sure
|
||||
# the token will not expire while in use
|
||||
expiry_cutoff = (
|
||||
datetime.datetime.utcnow() + datetime.timedelta(seconds=5))
|
||||
if cached_tokens.get('expires_at') > expiry_cutoff:
|
||||
tokens = cached_tokens
|
||||
else:
|
||||
# renew the tokens with the cached `refresh_token`
|
||||
refresh_token = self._decrypt_token(cached_tokens.get(
|
||||
'refresh_token'))
|
||||
tokens = self._get_and_cache_oauth_tokens(
|
||||
refresh_token=refresh_token)
|
||||
|
||||
# if no tokens are cached or something went wrong with
|
||||
# retreiving/renewing them, go and create new tokens
|
||||
if not tokens:
|
||||
tokens = self._get_and_cache_oauth_tokens()
|
||||
return self._decrypt_token(tokens.get('access_token'))
|
||||
|
||||
def _get_headers(self):
|
||||
"""
|
||||
Headers to send along with the request-- used for authentication.
|
||||
"""
|
||||
return {'Authorization': f'Token {settings.BADGR_API_TOKEN}'}
|
||||
access_token = self._get_access_token()
|
||||
return {'Authorization': u'Bearer {}'.format(access_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)
|
||||
slug = badge_class.badgr_server_slug
|
||||
if slug in BadgrBackend.badges:
|
||||
return
|
||||
response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
Tests for BadgrBackend
|
||||
"""
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
import datetime
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
import json
|
||||
import ddt
|
||||
from django.db.models.fields.files import ImageFieldFile
|
||||
import httpretty
|
||||
from django.test.utils import override_settings
|
||||
from lazy.lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
|
||||
|
||||
from edx_django_utils.cache import TieredCache
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from common.djangoapps.track.tests import EventTrackingTestCase
|
||||
from lms.djangoapps.badges.backends.badgr import BadgrBackend
|
||||
@@ -21,18 +22,22 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
BADGR_SETTINGS = {
|
||||
'BADGR_API_TOKEN': '12345',
|
||||
'BADGR_BASE_URL': 'https://example.com',
|
||||
'BADGR_ISSUER_SLUG': 'test-issuer',
|
||||
'BADGR_USERNAME': 'example@example.com',
|
||||
'BADGR_PASSWORD': 'password',
|
||||
'BADGR_TOKENS_CACHE_KEY': 'badgr-test-cache-key'
|
||||
}
|
||||
|
||||
# Should be the hashed result of test_slug as the slug, and test_component as the component
|
||||
EXAMPLE_SLUG = '15bb687e0c59ef2f0a49f6838f511bf4ca6c566dd45da6293cabbd9369390e1a'
|
||||
BADGR_SERVER_SLUG = 'test_badgr_server_slug'
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@ddt.ddt
|
||||
@override_settings(**BADGR_SETTINGS)
|
||||
@httpretty.activate
|
||||
class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
"""
|
||||
Tests the BadgeHandler object
|
||||
@@ -46,8 +51,8 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
# 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)
|
||||
start=datetime.datetime(year=2015, month=5, day=19),
|
||||
end=datetime.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')
|
||||
@@ -58,6 +63,8 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
course_id=self.course.location.course_key, issuing_component=''
|
||||
)
|
||||
self.no_course_badge_class = BadgeClassFactory.create()
|
||||
TieredCache.dangerous_clear_all_tiers()
|
||||
httpretty.httpretty.reset()
|
||||
|
||||
@lazy
|
||||
def handler(self):
|
||||
@@ -67,29 +74,38 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
"""
|
||||
return BadgrBackend()
|
||||
|
||||
def _mock_badgr_tokens_api(self, result):
|
||||
assert httpretty.is_enabled()
|
||||
responses = [httpretty.Response(body=json.dumps(result),
|
||||
content_type='application/json')]
|
||||
httpretty.register_uri(httpretty.POST,
|
||||
'https://example.com/o/token',
|
||||
responses=responses)
|
||||
|
||||
def test_urls(self):
|
||||
"""
|
||||
Make sure the handler generates the correct URLs for different API tasks.
|
||||
"""
|
||||
assert self.handler._base_url == 'https://example.com/v1/issuer/issuers/test-issuer'
|
||||
assert self.handler._base_url == 'https://example.com/v2/issuers/test-issuer'
|
||||
# lint-amnesty, pylint: disable=no-member
|
||||
assert self.handler._badge_create_url == 'https://example.com/v1/issuer/issuers/test-issuer/badges'
|
||||
assert self.handler._badge_create_url == 'https://example.com/v2/issuers/test-issuer/badgeclasses'
|
||||
# lint-amnesty, pylint: disable=no-member
|
||||
assert self.handler._badge_url('test_slug_here') ==\
|
||||
'https://example.com/v1/issuer/issuers/test-issuer/badges/test_slug_here'
|
||||
'https://example.com/v2/badgeclasses/test_slug_here'
|
||||
assert self.handler._assertion_url('another_test_slug') ==\
|
||||
'https://example.com/v1/issuer/issuers/test-issuer/badges/another_test_slug/assertions'
|
||||
'https://example.com/v2/badgeclasses/another_test_slug/assertions'
|
||||
|
||||
def check_headers(self, headers):
|
||||
"""
|
||||
Verify the a headers dict from a requests call matches the proper auth info.
|
||||
"""
|
||||
assert headers == {'Authorization': 'Token 12345'}
|
||||
assert headers == {'Authorization': 'Bearer 12345'}
|
||||
|
||||
def test_get_headers(self):
|
||||
"""
|
||||
Check to make sure the handler generates appropriate HTTP headers.
|
||||
"""
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.check_headers(self.handler._get_headers()) # lint-amnesty, pylint: disable=no-member
|
||||
|
||||
@patch('requests.post')
|
||||
@@ -97,24 +113,23 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
"""
|
||||
Verify badge spec creation works.
|
||||
"""
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_badge(self.badge_class)
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == 'https://example.com/v1/issuer/issuers/test-issuer/badges'
|
||||
assert args[0] == 'https://example.com/v2/issuers/test-issuer/badgeclasses'
|
||||
assert kwargs['files']['image'][0] == self.badge_class.image.name
|
||||
assert isinstance(kwargs['files']['image'][1], ImageFieldFile)
|
||||
assert kwargs['files']['image'][2] == 'image/png'
|
||||
self.check_headers(kwargs['headers'])
|
||||
assert kwargs['data'] ==\
|
||||
{'name': 'Test Badge',
|
||||
'slug': EXAMPLE_SLUG,
|
||||
'criteria': 'https://example.com/syllabus',
|
||||
'criteriaUrl': 'https://example.com/syllabus',
|
||||
'description': "Yay! It's a test badge."}
|
||||
|
||||
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.
|
||||
"""
|
||||
BadgrBackend.badges.append(EXAMPLE_SLUG)
|
||||
BadgrBackend.badges.append(BADGR_SERVER_SLUG)
|
||||
self.handler._create_badge = Mock()
|
||||
self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
|
||||
assert not self.handler._create_badge.called
|
||||
@@ -135,13 +150,16 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
response.status_code = 200
|
||||
get.return_value = response
|
||||
assert 'test_componenttest_slug' not in BadgrBackend.badges
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_badge = Mock()
|
||||
self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
|
||||
assert get.called
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == ('https://example.com/v1/issuer/issuers/test-issuer/badges/' + EXAMPLE_SLUG)
|
||||
assert args[0] == (
|
||||
'https://example.com/v2/badgeclasses/' +
|
||||
BADGR_SERVER_SLUG)
|
||||
self.check_headers(kwargs['headers'])
|
||||
assert EXAMPLE_SLUG in BadgrBackend.badges
|
||||
assert BADGR_SERVER_SLUG in BadgrBackend.badges
|
||||
assert not self.handler._create_badge.called
|
||||
|
||||
@patch('requests.get')
|
||||
@@ -149,12 +167,13 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
response = Mock()
|
||||
response.status_code = 404
|
||||
get.return_value = response
|
||||
assert EXAMPLE_SLUG not in BadgrBackend.badges
|
||||
assert BADGR_SERVER_SLUG not in BadgrBackend.badges
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_badge = Mock()
|
||||
self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
|
||||
assert self.handler._create_badge.called
|
||||
assert self.handler._create_badge.call_args == call(self.badge_class)
|
||||
assert EXAMPLE_SLUG in BadgrBackend.badges
|
||||
assert BADGR_SERVER_SLUG in BadgrBackend.badges
|
||||
|
||||
@patch('requests.post')
|
||||
def test_badge_creation_event(self, post):
|
||||
@@ -162,21 +181,26 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
'json': {'id': 'http://www.example.com/example'},
|
||||
'image': 'http://www.example.com/example.png',
|
||||
'badge': 'test_assertion_slug',
|
||||
'issuer': 'https://example.com/v1/issuer/issuers/test-issuer',
|
||||
'issuer': 'https://example.com/v2/issuers/test-issuer',
|
||||
}
|
||||
response = Mock()
|
||||
response.json.return_value = result
|
||||
post.return_value = response
|
||||
self.recreate_tracker()
|
||||
self.handler._get_access_token = Mock(return_value='12345')
|
||||
self.handler._create_assertion(self.badge_class, self.user, 'https://example.com/irrefutable_proof') # lint-amnesty, pylint: disable=no-member
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == (('https://example.com/v1/issuer/issuers/test-issuer/badges/' + EXAMPLE_SLUG) + '/assertions')
|
||||
assert args[0] == ((
|
||||
'https://example.com/v2/badgeclasses/' +
|
||||
BADGR_SERVER_SLUG) +
|
||||
'/assertions')
|
||||
self.check_headers(kwargs['headers'])
|
||||
assertion = BadgeAssertion.objects.get(user=self.user, badge_class__course_id=self.course.location.course_key)
|
||||
assert assertion.data == result
|
||||
assert assertion.image_url == 'http://www.example.com/example.png'
|
||||
assert assertion.assertion_url == 'http://www.example.com/example'
|
||||
assert kwargs['data'] == {'email': 'example@example.com', 'evidence': 'https://example.com/irrefutable_proof'}
|
||||
assert kwargs['json'] == {"recipient": {"identity": 'example@example.com', "type": "email"},
|
||||
"evidence": [{"url": 'https://example.com/irrefutable_proof'}]}
|
||||
assert_event_matches({
|
||||
'name': 'edx.badge.assertion.created',
|
||||
'data': {
|
||||
@@ -186,9 +210,87 @@ class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
|
||||
'assertion_id': assertion.id,
|
||||
'badge_name': 'Test Badge',
|
||||
'badge_slug': 'test_slug',
|
||||
'badge_badgr_server_slug': BADGR_SERVER_SLUG,
|
||||
'issuing_component': 'test_component',
|
||||
'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',
|
||||
'issuer': 'https://example.com/v2/issuers/test-issuer',
|
||||
}
|
||||
}, self.get_event())
|
||||
|
||||
def test_get_new_tokens(self):
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
self.handler._get_and_cache_oauth_tokens()
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'username': ['example@example.com'],
|
||||
'password': ['password']}
|
||||
|
||||
def test_renew_tokens(self):
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
self.handler._get_and_cache_oauth_tokens(refresh_token='67890')
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'grant_type': ['refresh_token'],
|
||||
'refresh_token': ['67890']}
|
||||
|
||||
def test_get_access_token_from_cache_valid(self):
|
||||
encrypted_access_token = self.handler._encrypt_token('12345')
|
||||
encrypted_refresh_token = self.handler._encrypt_token('67890')
|
||||
tokens = {
|
||||
'access_token': encrypted_access_token,
|
||||
'refresh_token': encrypted_refresh_token,
|
||||
'expires_at': datetime.datetime.utcnow() + datetime.timedelta(seconds=20)
|
||||
}
|
||||
TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None)
|
||||
|
||||
access_token = self.handler._get_access_token()
|
||||
assert access_token == self.handler._decrypt_token(
|
||||
tokens.get('access_token'))
|
||||
|
||||
def test_get_access_token_from_cache_expired(self):
|
||||
encrypted_access_token = self.handler._encrypt_token('12345')
|
||||
encrypted_refresh_token = self.handler._encrypt_token('67890')
|
||||
tokens = {
|
||||
'access_token': encrypted_access_token,
|
||||
'refresh_token': encrypted_refresh_token,
|
||||
'expires_at': datetime.datetime.utcnow()
|
||||
}
|
||||
TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None)
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
access_token = self.handler._get_access_token()
|
||||
assert access_token == result.get('access_token')
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'grant_type': ['refresh_token'],
|
||||
'refresh_token': [self.handler._decrypt_token(
|
||||
tokens.get('refresh_token'))]}
|
||||
|
||||
def test_get_access_token_from_cache_none(self):
|
||||
result = {
|
||||
'access_token': '12345',
|
||||
'refresh_token': '67890',
|
||||
'expires_in': 86400,
|
||||
}
|
||||
self._mock_badgr_tokens_api(result)
|
||||
access_token = self.handler._get_access_token()
|
||||
assert access_token == result.get('access_token')
|
||||
assert 'o/token' in httpretty.httpretty.last_request.path
|
||||
assert httpretty.httpretty.last_request.parsed_body == {
|
||||
'username': ['example@example.com'],
|
||||
'password': ['password']}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('badges', '0003_schema__add_event_configuration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='badgeclass',
|
||||
name='badgr_server_slug',
|
||||
field=models.SlugField(blank=True, default='', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -56,6 +56,7 @@ class BadgeClass(models.Model):
|
||||
.. no_pii:
|
||||
"""
|
||||
slug = models.SlugField(max_length=255, validators=[validate_lowercase])
|
||||
badgr_server_slug = models.SlugField(max_length=255, default='', blank=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)
|
||||
|
||||
@@ -48,6 +48,7 @@ class BadgeClassFactory(factory.django.DjangoModelFactory):
|
||||
model = BadgeClass
|
||||
|
||||
slug = 'test_slug'
|
||||
badgr_server_slug = 'test_badgr_server_slug'
|
||||
issuing_component = 'test_component'
|
||||
display_name = 'Test Badge'
|
||||
description = "Yay! It's a test badge."
|
||||
|
||||
@@ -209,7 +209,11 @@ class BadgeClassTest(ModuleStoreTestCase):
|
||||
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=user)
|
||||
assert list(badge_class.get_for_user(user)) == [assertion]
|
||||
|
||||
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.badgr.BadgrBackend', BADGR_API_TOKEN='test')
|
||||
@override_settings(
|
||||
BADGING_BACKEND='lms.djangoapps.badges.backends.badgr.BadgrBackend',
|
||||
BADGR_USERNAME='example@example.com',
|
||||
BADGR_PASSWORD='password',
|
||||
BADGR_TOKENS_CACHE_KEY='badgr-test-cache-key')
|
||||
@patch('lms.djangoapps.badges.backends.badgr.BadgrBackend.award')
|
||||
def test_award(self, mock_award):
|
||||
"""
|
||||
|
||||
@@ -3403,13 +3403,6 @@ CERT_NAME_LONG = "Certificate of Achievement"
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGING_BACKEND = 'lms.djangoapps.badges.backends.badgr.BadgrBackend'
|
||||
|
||||
# .. setting_name: BADGR_API_TOKEN
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The API token string for Badgr. You should be able to create this via Badgr's settings. See
|
||||
# https://github.com/concentricsky/badgr-server for details on setting up Badgr.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_API_TOKEN = None
|
||||
|
||||
# .. setting_name: BADGR_BASE_URL
|
||||
# .. setting_default: 'http://localhost:8005'
|
||||
# .. setting_description: The base URL for the Badgr server.
|
||||
@@ -3424,6 +3417,29 @@ BADGR_BASE_URL = "http://localhost:8005"
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_ISSUER_SLUG = "example-issuer"
|
||||
|
||||
# .. setting_name: BADGR_USERNAME
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The username for Badgr. You should set up an issuer application with Badgr
|
||||
# (https://badgr.org/app-developers/). The username and password will then be used to create or renew
|
||||
# OAuth2 tokens.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_USERNAME = None
|
||||
|
||||
# .. setting_name: BADGR_PASSWORD
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The password for Badgr. You should set up an issuer application with Badgr
|
||||
# (https://badgr.org/app-developers/). The username and password will then be used to create or renew
|
||||
# OAuth2 tokens.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_PASSWORD = None
|
||||
|
||||
# .. setting_name: BADGR_TOKENS_CACHE_KEY
|
||||
# .. setting_default: None
|
||||
# .. setting_description: The cache key for Badgr API tokens. Once created, the tokens will be stored in cache.
|
||||
# Define the key here for setting and retrieveing the tokens.
|
||||
# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context.
|
||||
BADGR_TOKENS_CACHE_KEY = None
|
||||
|
||||
# .. setting_name: BADGR_TIMEOUT
|
||||
# .. setting_default: 10
|
||||
# .. setting_description: Number of seconds to wait on the badging server when contacting it before giving up.
|
||||
|
||||
Reference in New Issue
Block a user