diff --git a/cms/djangoapps/contentstore/views/tests/test_programs.py b/cms/djangoapps/contentstore/views/tests/test_programs.py
index 480a9cab01..4ec26edac8 100644
--- a/cms/djangoapps/contentstore/views/tests/test_programs.py
+++ b/cms/djangoapps/contentstore/views/tests/test_programs.py
@@ -29,7 +29,7 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule
@httpretty.activate
def test_programs_config_disabled(self):
"""Verify that the programs tab and creation button aren't rendered when config is disabled."""
- self.create_config(enable_studio_tab=False)
+ self.create_programs_config(enable_studio_tab=False)
self.mock_programs_api()
response = self.client.get(self.studio_home)
@@ -48,7 +48,7 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule
student = UserFactory(is_staff=False)
self.client.login(username=student.username, password='test')
- self.create_config()
+ self.create_programs_config()
self.mock_programs_api()
response = self.client.get(self.studio_home)
@@ -57,9 +57,9 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule
@httpretty.activate
def test_programs_displayed(self):
"""Verify that the programs tab and creation button can be rendered when config is enabled."""
- self.create_config()
# When no data is provided, expect creation prompt.
+ self.create_programs_config()
self.mock_programs_api(data={'results': []})
response = self.client.get(self.studio_home)
@@ -102,7 +102,7 @@ class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase
def test_authoring_header(self):
"""Verify that the header contains the expected text."""
self.client.login(username=self.staff.username, password='test')
- self.create_config()
+ self.create_programs_config()
response = self._assert_status(200)
self.assertIn("Program Administration", response.content)
@@ -116,7 +116,7 @@ class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase
self._assert_status(404)
# Enable Programs authoring interface
- self.create_config()
+ self.create_programs_config()
student = UserFactory(is_staff=False)
self.client.login(username=student.username, password='test')
@@ -134,13 +134,13 @@ class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase)
def test_config_disabled(self):
"""Ensure the endpoint returns 404 when Programs authoring is disabled."""
- self.create_config(enable_studio_tab=False)
+ self.create_programs_config(enable_studio_tab=False)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 404)
def test_not_logged_in(self):
"""Ensure the endpoint denies access to unauthenticated users."""
- self.create_config()
+ self.create_programs_config()
self.client.logout()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 302)
@@ -152,7 +152,7 @@ class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase)
Ensure the endpoint responds with a valid JSON payload when authoring
is enabled.
"""
- self.create_config()
+ self.create_programs_config()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
payload = json.loads(response.content)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 9648cdf496..281c42baa7 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -840,6 +840,8 @@ INSTALLED_APPS = (
# Microsite configuration application
'microsite_configuration',
+ # Credentials support
+ 'openedx.core.djangoapps.credentials',
)
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index b346e16bcd..986a97f3c4 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -1035,7 +1035,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_2.id, mode=course_mode)
self.client.login(username="jack", password="test")
- self.create_config()
+ self.create_programs_config()
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = self._create_program_data(
@@ -1068,7 +1068,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
self.client.login(username="jack", password="test")
- self.create_config()
+ self.create_programs_config()
with patch(
'student.views.get_programs_for_dashboard',
@@ -1098,7 +1098,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor')
self.client.login(username="jack", password="test")
- self.create_config()
+ self.create_programs_config()
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = self._create_program_data(
@@ -1119,7 +1119,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test")
- self.create_config()
+ self.create_programs_config()
program_data = self._create_program_data([(self.course_1.id, 'active')])
if key_remove and key_remove in program_data[unicode(self.course_1.id)]:
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 4abf192f22..c79f6bb187 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -123,6 +123,7 @@ from eventtracking import tracker
from notification_prefs.views import enable_notifications
# Note that this lives in openedx, so this dependency should be refactored.
+from openedx.core.djangoapps.credentials.utils import get_user_program_credentials
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard
@@ -609,6 +610,7 @@ def dashboard(request):
# This is passed along in the template context to allow rendering of
# program-related information on the dashboard.
course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments])
+ xseries_credentials = _get_xseries_credentials(user)
# Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict
@@ -732,6 +734,7 @@ def dashboard(request):
'nav_hidden': True,
'course_programs': course_programs,
'disable_courseware_js': True,
+ 'xseries_credentials': xseries_credentials,
}
return render_to_response('dashboard.html', context)
@@ -2410,3 +2413,34 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali
log.warning('Program structure is invalid, skipping display: %r', program)
return programs_data
+
+
+def _get_xseries_credentials(user):
+ """Return program credentials data required for display on
+ the learner dashboard.
+
+ Given a user, find all programs for which certificates have been earned
+ and return list of dictionaries of required program data.
+
+ Arguments:
+ user (User): user object for getting programs credentials.
+
+ Returns:
+ list of dict, containing data corresponding to the programs for which
+ the user has been awarded a credential.
+ """
+ programs_credentials = get_user_program_credentials(user)
+ credentials_data = []
+ for program in programs_credentials:
+ if program.get('category') == 'xseries':
+ try:
+ program_data = {
+ 'display_name': program['name'],
+ 'subtitle': program['subtitle'],
+ 'credential_url': program['credential_url'],
+ }
+ credentials_data.append(program_data)
+ except KeyError:
+ log.warning('Program structure is invalid: %r', program)
+
+ return credentials_data
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 793a6e9434..f8dac1dd51 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -735,6 +735,7 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U
#### JWT configuration ####
JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER)
JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION)
+JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {}))
################# PROCTORING CONFIGURATION ##################
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 31cd0a3f69..e49faa4880 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1930,6 +1930,9 @@ INSTALLED_APPS = (
'openedx.core.djangoapps.self_paced',
'sorl.thumbnail',
+
+ # Credentials support
+ 'openedx.core.djangoapps.credentials',
)
# Migrations which are not in the standard module "migrations"
@@ -2006,6 +2009,18 @@ SOCIAL_MEDIA_FOOTER_NAMES = [
"reddit",
]
+# JWT Settings
+JWT_AUTH = {
+ 'JWT_SECRET_KEY': None,
+ 'JWT_ALGORITHM': 'HS256',
+ 'JWT_VERIFY_EXPIRATION': True,
+ 'JWT_ISSUER': None,
+ 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'),
+ 'JWT_AUDIENCE': None,
+ 'JWT_LEEWAY': 1,
+ 'JWT_DECODE_HANDLER': 'openedx.core.lib.api.jwt_decode_handler.decode',
+}
+
# The footer URLs dictionary maps social footer names
# to URLs defined in configuration.
SOCIAL_MEDIA_FOOTER_URLS = {}
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index 95a8572a67..3445bf21a5 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -224,6 +224,13 @@ CORS_ALLOW_CREDENTIALS = True
CORS_ORIGIN_WHITELIST = ()
CORS_ORIGIN_ALLOW_ALL = True
+# JWT settings for devstack
+JWT_AUTH.update({
+ 'JWT_ALGORITHM': 'HS256',
+ 'JWT_SECRET_KEY': 'lms-secret',
+ 'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2',
+ 'JWT_AUDIENCE': 'lms-key',
+})
#####################################################################
# See if the developer has any local overrides.
diff --git a/lms/envs/test.py b/lms/envs/test.py
index e036dd2565..2a5e025107 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -563,3 +563,9 @@ FEATURES['ORGANIZATIONS_APP'] = True
# Financial assistance page
FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True
+
+JWT_AUTH.update({
+ 'JWT_SECRET_KEY': 'test-secret',
+ 'JWT_ISSUER': 'https://test-provider/oauth2',
+ 'JWT_AUDIENCE': 'test-key',
+})
diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss
index 1901dcae37..a1eab8972b 100644
--- a/lms/static/sass/multicourse/_dashboard.scss
+++ b/lms/static/sass/multicourse/_dashboard.scss
@@ -32,6 +32,32 @@
}
}
+ .wrapper-xseries-certificates{
+ @include float(right);
+ @include margin-left(flex-gutter());
+ width: flex-grid(3);
+
+ .title{
+ @extend %t-title7;
+ @extend %t-weight4;
+ }
+
+ ul{
+ @include padding-left(0);
+ margin-top: ($baseline/2);
+ }
+
+ li{
+ @include line-height(20);
+ list-style-type: none;
+ }
+
+ .copy {
+ @extend %t-copy-sub1;
+ margin-top: ($baseline/2);
+ }
+ }
+
.profile-sidebar {
background: transparent;
@include float(right);
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index eee0f7cb84..337018224c 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -172,6 +172,19 @@ import json
+ % if xseries_credentials:
+
+
${_("XSeries Program Certificates")}
+
${_("You have received a certificate for the following XSeries programs:")}
+
+
+ % endif
diff --git a/openedx/core/djangoapps/credentials/__init__.py b/openedx/core/djangoapps/credentials/__init__.py
new file mode 100644
index 0000000000..b7c6418ae6
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/__init__.py
@@ -0,0 +1,6 @@
+"""
+edX Platform support for credentials.
+
+This package will be used as a wrapper for interacting with the credentials
+service.
+"""
diff --git a/openedx/core/djangoapps/credentials/admin.py b/openedx/core/djangoapps/credentials/admin.py
new file mode 100644
index 0000000000..6d94abb06e
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/admin.py
@@ -0,0 +1,16 @@
+"""
+Django admin pages for credentials support models.
+"""
+
+from django.contrib import admin
+
+from config_models.admin import ConfigurationModelAdmin
+
+from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
+
+
+class CredentialsApiConfigAdmin(ConfigurationModelAdmin): # pylint: disable=missing-docstring
+ pass
+
+
+admin.site.register(CredentialsApiConfig, CredentialsApiConfigAdmin)
diff --git a/openedx/core/djangoapps/credentials/migrations/0001_initial.py b/openedx/core/djangoapps/credentials/migrations/0001_initial.py
new file mode 100644
index 0000000000..02ed45cb05
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/migrations/0001_initial.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CredentialsApiConfig',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
+ ('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
+ ('internal_service_url', models.URLField(verbose_name='Internal Service URL')),
+ ('public_service_url', models.URLField(verbose_name='Public Service URL')),
+ ('enable_learner_issuance', models.BooleanField(default=False, help_text='Enable issuance of credentials via Credential Service.', verbose_name='Enable Learner Issuance')),
+ ('enable_studio_authoring', models.BooleanField(default=False, help_text='Enable authoring of Credential Service credentials in Studio.', verbose_name='Enable Authoring of Credential in Studio')),
+ ('cache_ttl', models.PositiveIntegerField(default=0, help_text='Specified in seconds. Enable caching by setting this to a value greater than 0.', verbose_name='Cache Time To Live')),
+ ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
+ ],
+ options={
+ 'ordering': ('-change_date',),
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/openedx/core/djangoapps/credentials/migrations/__init__.py b/openedx/core/djangoapps/credentials/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/credentials/models.py b/openedx/core/djangoapps/credentials/models.py
new file mode 100644
index 0000000000..630bfa41f5
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/models.py
@@ -0,0 +1,82 @@
+"""
+Models for credentials support for the LMS and Studio.
+"""
+
+from urlparse import urljoin
+
+from django.utils.translation import ugettext_lazy as _
+from django.db import models
+
+from config_models.models import ConfigurationModel
+
+
+class CredentialsApiConfig(ConfigurationModel):
+ """
+ Manages configuration for connecting to the Credential service and using its
+ API.
+ """
+ OAUTH2_CLIENT_NAME = 'credentials'
+ API_NAME = 'credentials'
+ CACHE_KEY = 'credentials.api.data'
+
+ internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
+ public_service_url = models.URLField(verbose_name=_("Public Service URL"))
+
+ enable_learner_issuance = models.BooleanField(
+ verbose_name=_("Enable Learner Issuance"),
+ default=False,
+ help_text=_(
+ "Enable issuance of credentials via Credential Service."
+ )
+ )
+ enable_studio_authoring = models.BooleanField(
+ verbose_name=_("Enable Authoring of Credential in Studio"),
+ default=False,
+ help_text=_(
+ "Enable authoring of Credential Service credentials in Studio."
+ )
+ )
+ cache_ttl = models.PositiveIntegerField(
+ verbose_name=_("Cache Time To Live"),
+ default=0,
+ help_text=_(
+ "Specified in seconds. Enable caching by setting this to a value greater than 0."
+ )
+ )
+
+ def __unicode__(self):
+ return self.public_api_url
+
+ @property
+ def internal_api_url(self):
+ """
+ Generate a URL based on internal service URL and API version number.
+ """
+ return urljoin(self.internal_service_url, '/api/v1/')
+
+ @property
+ def public_api_url(self):
+ """
+ Generate a URL based on public service URL and API version number.
+ """
+ return urljoin(self.public_service_url, '/api/v1/')
+
+ @property
+ def is_learner_issuance_enabled(self):
+ """
+ Indicates whether the learner credential should be enabled or not.
+ """
+ return self.enabled and self.enable_learner_issuance
+
+ @property
+ def is_studio_authoring_enabled(self):
+ """
+ Indicates whether Studio functionality related to Credential should
+ be enabled or not.
+ """
+ return self.enabled and self.enable_studio_authoring
+
+ @property
+ def is_cache_enabled(self):
+ """Whether responses from the Credentials API will be cached."""
+ return self.cache_ttl > 0
diff --git a/openedx/core/djangoapps/credentials/tests/__init__.py b/openedx/core/djangoapps/credentials/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openedx/core/djangoapps/credentials/tests/mixins.py b/openedx/core/djangoapps/credentials/tests/mixins.py
new file mode 100644
index 0000000000..ae7b73254a
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/tests/mixins.py
@@ -0,0 +1,164 @@
+"""Mixins for use during testing."""
+import json
+
+import httpretty
+
+from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
+
+
+class CredentialsApiConfigMixin(object):
+ """ Utilities for working with Credentials configuration during testing."""
+
+ CREDENTIALS_DEFAULTS = {
+ 'enabled': True,
+ 'internal_service_url': 'http://internal.credentials.org/',
+ 'public_service_url': 'http://public.credentials.org/',
+ 'enable_learner_issuance': True,
+ 'enable_studio_authoring': True,
+ 'cache_ttl': 0,
+ }
+
+ def create_credentials_config(self, **kwargs):
+ """ Creates a new CredentialsApiConfig with DEFAULTS, updated with any
+ provided overrides.
+ """
+ fields = dict(self.CREDENTIALS_DEFAULTS, **kwargs)
+ CredentialsApiConfig(**fields).save()
+
+ return CredentialsApiConfig.current()
+
+
+class CredentialsDataMixin(object):
+ """Mixin mocking Credentials API URLs and providing fake data for testing."""
+ CREDENTIALS_API_RESPONSE = {
+ "next": None,
+ "results": [
+ {
+ "id": 1,
+ "username": "test",
+ "credential": {
+ "credential_id": 1,
+ "program_id": 1
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-1",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-1/"
+ },
+ {
+ "id": 2,
+ "username": "test",
+ "credential": {
+ "credential_id": 2,
+ "program_id": 2
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-2",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-2/"
+ },
+ {
+ "id": 3,
+ "username": "test",
+ "credential": {
+ "credential_id": 3,
+ "program_id": 3
+ },
+ "status": "revoked",
+ "uuid": "dummy-uuid-3",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-3/"
+ },
+ {
+ "id": 4,
+ "username": "test",
+ "credential": {
+ "course_id": "edx/test01/2015",
+ "credential_id": 4,
+ "certificate_type": "honor"
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-4",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-4/"
+ },
+ {
+ "id": 5,
+ "username": "test",
+ "credential": {
+ "course_id": "edx/test02/2015",
+ "credential_id": 5,
+ "certificate_type": "verified"
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-5",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-5/"
+ },
+ {
+ "id": 6,
+ "username": "test",
+ "credential": {
+ "course_id": "edx/test03/2015",
+ "credential_id": 6,
+ "certificate_type": "honor"
+ },
+ "status": "revoked",
+ "uuid": "dummy-uuid-6",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-6/"
+ }
+ ]
+ }
+
+ CREDENTIALS_NEXT_API_RESPONSE = {
+ "next": None,
+ "results": [
+ {
+ "id": 7,
+ "username": "test",
+ "credential": {
+ "credential_id": 7,
+ "program_id": 7
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-7",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-7"
+ },
+ {
+ "id": 8,
+ "username": "test",
+ "credential": {
+ "credential_id": 8,
+ "program_id": 8
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-8",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-8/"
+ }
+ ]
+ }
+
+ def mock_credentials_api(self, user, data=None, status_code=200, reset_url=True, is_next_page=False):
+ """Utility for mocking out Credentials API URLs."""
+ self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Credentials API calls.')
+ internal_api_url = CredentialsApiConfig.current().internal_api_url.strip('/')
+
+ url = internal_api_url + '/user_credentials/?username=' + user.username
+
+ if reset_url:
+ httpretty.reset()
+
+ if data is None:
+ data = self.CREDENTIALS_API_RESPONSE
+
+ body = json.dumps(data)
+
+ if is_next_page:
+ next_page_url = internal_api_url + '/user_credentials/?page=2&username=' + user.username
+ self.CREDENTIALS_NEXT_API_RESPONSE['next'] = next_page_url
+ next_page_body = json.dumps(self.CREDENTIALS_NEXT_API_RESPONSE)
+ httpretty.register_uri(
+ httpretty.GET, next_page_url, body=body, content_type='application/json', status=status_code
+ )
+ httpretty.register_uri(
+ httpretty.GET, url, body=next_page_body, content_type='application/json', status=status_code
+ )
+ else:
+ httpretty.register_uri(
+ httpretty.GET, url, body=body, content_type='application/json', status=status_code
+ )
diff --git a/openedx/core/djangoapps/credentials/tests/test_models.py b/openedx/core/djangoapps/credentials/tests/test_models.py
new file mode 100644
index 0000000000..066b7ac434
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/tests/test_models.py
@@ -0,0 +1,47 @@
+"""Tests for models supporting Credentials-related functionality."""
+
+from django.test import TestCase
+from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
+
+
+class TestCredentialsApiConfig(CredentialsApiConfigMixin, TestCase):
+ """Tests covering the CredentialsApiConfig model."""
+ def test_url_construction(self):
+ """Verify that URLs returned by the model are constructed correctly."""
+ credentials_config = self.create_credentials_config()
+
+ self.assertEqual(
+ credentials_config.internal_api_url,
+ credentials_config.internal_service_url.strip('/') + '/api/v1/')
+
+ self.assertEqual(
+ credentials_config.public_api_url,
+ credentials_config.public_service_url.strip('/') + '/api/v1/')
+
+ def test_is_learner_issuance_enabled(self):
+ """
+ Verify that the property controlling display on the student dashboard is only True
+ when configuration is enabled and all required configuration is provided.
+ """
+ credentials_config = self.create_credentials_config(enabled=False)
+ self.assertFalse(credentials_config.is_learner_issuance_enabled)
+
+ credentials_config = self.create_credentials_config(enable_learner_issuance=False)
+ self.assertFalse(credentials_config.is_learner_issuance_enabled)
+
+ credentials_config = self.create_credentials_config()
+ self.assertTrue(credentials_config.is_learner_issuance_enabled)
+
+ def test_is_studio_authoring_enabled(self):
+ """
+ Verify that the property controlling display in the Studio authoring is only True
+ when configuration is enabled and all required configuration is provided.
+ """
+ credentials_config = self.create_credentials_config(enabled=False)
+ self.assertFalse(credentials_config.is_studio_authoring_enabled)
+
+ credentials_config = self.create_credentials_config(enable_studio_authoring=False)
+ self.assertFalse(credentials_config.is_studio_authoring_enabled)
+
+ credentials_config = self.create_credentials_config()
+ self.assertTrue(credentials_config.is_studio_authoring_enabled)
diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py
new file mode 100644
index 0000000000..544167100a
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/tests/test_utils.py
@@ -0,0 +1,117 @@
+"""Tests covering Credentials utilities."""
+from django.core.cache import cache
+from django.test import TestCase
+import httpretty
+from oauth2_provider.tests.factories import ClientFactory
+from provider.constants import CONFIDENTIAL
+
+from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin
+from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
+from openedx.core.djangoapps.credentials.utils import (
+ get_user_credentials, get_user_program_credentials
+)
+from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+from student.tests.factories import UserFactory
+
+
+class TestCredentialsRetrieval(ProgramsApiConfigMixin, CredentialsApiConfigMixin, CredentialsDataMixin,
+ ProgramsDataMixin, TestCase):
+ """ Tests covering the retrieval of user credentials from the Credentials
+ service.
+ """
+ def setUp(self):
+ super(TestCredentialsRetrieval, self).setUp()
+
+ ClientFactory(name=CredentialsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
+ ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
+ self.user = UserFactory()
+
+ cache.clear()
+
+ @httpretty.activate
+ def test_get_user_credentials(self):
+ """Verify user credentials data can be retrieve."""
+ self.create_credentials_config()
+ self.mock_credentials_api(self.user)
+
+ actual = get_user_credentials(self.user)
+ self.assertEqual(actual, self.CREDENTIALS_API_RESPONSE['results'])
+
+ @httpretty.activate
+ def test_get_user_credentials_caching(self):
+ """Verify that when enabled, the cache is used for non-staff users."""
+ self.create_credentials_config(cache_ttl=1)
+ self.mock_credentials_api(self.user)
+
+ # Warm up the cache.
+ get_user_credentials(self.user)
+
+ # Hit the cache.
+ get_user_credentials(self.user)
+
+ # Verify only one request was made.
+ self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
+
+ staff_user = UserFactory(is_staff=True)
+
+ # Hit the Credentials API twice.
+ for _ in range(2):
+ get_user_credentials(staff_user)
+
+ # Verify that three requests have been made (one for student, two for staff).
+ self.assertEqual(len(httpretty.httpretty.latest_requests), 3)
+
+ def test_get_user_program_credentials_issuance_disable(self):
+ """Verify that user program credentials cannot be retrieved if issuance is disabled."""
+ self.create_credentials_config(enable_learner_issuance=False)
+ actual = get_user_program_credentials(self.user)
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_user_program_credentials_no_credential(self):
+ """Verify behavior if no credential exist."""
+ self.create_credentials_config()
+ self.mock_credentials_api(self.user, data={'results': []})
+ actual = get_user_program_credentials(self.user)
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_user_programs_credentials(self):
+ """Verify program credentials data can be retrieved and parsed correctly."""
+ # create credentials and program configuration
+ self.create_credentials_config()
+ self.create_programs_config()
+
+ # Mocking the API responses from programs and credentials
+ self.mock_programs_api()
+ self.mock_credentials_api(self.user, reset_url=False)
+
+ actual = get_user_program_credentials(self.user)
+ expected = self.PROGRAMS_API_RESPONSE['results']
+ expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url']
+ expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url']
+
+ # checking response from API is as expected
+ self.assertEqual(len(actual), 2)
+ self.assertEqual(actual, expected)
+
+ @httpretty.activate
+ def test_get_user_program_credentials_revoked(self):
+ """Verify behavior if credential revoked."""
+ self.create_credentials_config()
+ credential_data = {"results": [
+ {
+ "id": 1,
+ "username": "test",
+ "credential": {
+ "credential_id": 1,
+ "program_id": 1
+ },
+ "status": "revoked",
+ "uuid": "dummy-uuid-1"
+ }
+ ]}
+ self.mock_credentials_api(self.user, data=credential_data)
+ actual = get_user_program_credentials(self.user)
+ self.assertEqual(actual, [])
diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py
new file mode 100644
index 0000000000..a61c17ae96
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/utils.py
@@ -0,0 +1,66 @@
+"""Helper functions for working with Credentials."""
+from __future__ import unicode_literals
+import logging
+
+from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
+from openedx.core.djangoapps.programs.utils import get_programs_for_credentials
+from openedx.core.lib.edx_api_utils import get_edx_api_data
+
+
+log = logging.getLogger(__name__)
+
+
+def get_user_credentials(user):
+ """Given a user, get credentials earned from the Credentials service.
+ Arguments:
+ user (User): The user to authenticate as when requesting credentials.
+ Returns:
+ list of dict, representing credentials returned by the Credentials
+ service.
+ """
+ credential_configuration = CredentialsApiConfig.current()
+ user_query = {'username': user.username}
+ # Bypass caching for staff users, who may be generating credentials and
+ # want to see them displayed immediately.
+ use_cache = credential_configuration.is_cache_enabled and not user.is_staff
+ cache_key = credential_configuration.CACHE_KEY + '.' + user.username if use_cache else None
+
+ credentials = get_edx_api_data(
+ credential_configuration, user, 'user_credentials', querystring=user_query, cache_key=cache_key
+ )
+ return credentials
+
+
+def get_user_program_credentials(user):
+ """Given a user, get the list of all program credentials earned and returns
+ list of dictionaries containing related programs data.
+
+ Arguments:
+ user (User): The user object for getting programs credentials.
+
+ Returns:
+ list, containing programs dictionaries.
+ """
+ programs_credentials_data = []
+ credential_configuration = CredentialsApiConfig.current()
+ if not credential_configuration.is_learner_issuance_enabled:
+ log.debug('Display of certificates for programs is disabled.')
+ return programs_credentials_data
+
+ credentials = get_user_credentials(user)
+ if not credentials:
+ log.info('No credential earned by the given user.')
+ return programs_credentials_data
+
+ programs_credentials = []
+ for credential in credentials:
+ try:
+ if 'program_id' in credential['credential'] and credential['status'] == 'awarded':
+ programs_credentials.append(credential)
+ except KeyError:
+ log.exception('Invalid credential structure: %r', credential)
+
+ if programs_credentials:
+ programs_credentials_data = get_programs_for_credentials(user, programs_credentials)
+
+ return programs_credentials_data
diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py
index c3a013ac2b..069051ef51 100644
--- a/openedx/core/djangoapps/programs/models.py
+++ b/openedx/core/djangoapps/programs/models.py
@@ -18,6 +18,7 @@ class ProgramsApiConfig(ConfigurationModel):
"""
OAUTH2_CLIENT_NAME = 'programs'
CACHE_KEY = 'programs.api.data'
+ API_NAME = 'programs'
api_version_number = models.IntegerField(verbose_name=_("API Version"))
diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py
index 42ed1b7d35..b78862202e 100644
--- a/openedx/core/djangoapps/programs/tests/mixins.py
+++ b/openedx/core/djangoapps/programs/tests/mixins.py
@@ -22,7 +22,7 @@ class ProgramsApiConfigMixin(object):
'enable_certification': True,
}
- def create_config(self, **kwargs):
+ def create_programs_config(self, **kwargs):
"""Creates a new ProgramsApiConfig with DEFAULTS, updated with any provided overrides."""
fields = dict(self.DEFAULTS, **kwargs)
ProgramsApiConfig(**fields).save()
@@ -185,6 +185,31 @@ class ProgramsDataMixin(object):
]
}
+ PROGRAMS_CREDENTIALS_DATA = [
+ {
+ "id": 1,
+ "username": "test",
+ "credential": {
+ "credential_id": 1,
+ "program_id": 1
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-1",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-1/"
+ },
+ {
+ "id": 2,
+ "username": "test",
+ "credential": {
+ "credential_id": 2,
+ "program_id": 2
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-2",
+ "certificate_url": "http://credentials.edx.org/credentials/dummy-uuid-2/"
+ }
+ ]
+
def mock_programs_api(self, data=None, status_code=200):
"""Utility for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
diff --git a/openedx/core/djangoapps/programs/tests/test_models.py b/openedx/core/djangoapps/programs/tests/test_models.py
index b41edd4348..5f0c3f7cfe 100644
--- a/openedx/core/djangoapps/programs/tests/test_models.py
+++ b/openedx/core/djangoapps/programs/tests/test_models.py
@@ -14,7 +14,7 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
"""Tests covering the ProgramsApiConfig model."""
def test_url_construction(self, _mock_cache):
"""Verify that URLs returned by the model are constructed correctly."""
- programs_config = self.create_config()
+ programs_config = self.create_programs_config()
self.assertEqual(
programs_config.internal_api_url,
@@ -43,7 +43,7 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
@ddt.unpack
def test_cache_control(self, cache_ttl, is_cache_enabled, _mock_cache):
"""Verify the behavior of the property controlling whether API responses are cached."""
- programs_config = self.create_config(cache_ttl=cache_ttl)
+ programs_config = self.create_programs_config(cache_ttl=cache_ttl)
self.assertEqual(programs_config.is_cache_enabled, is_cache_enabled)
def test_is_student_dashboard_enabled(self, _mock_cache):
@@ -51,13 +51,13 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
Verify that the property controlling display on the student dashboard is only True
when configuration is enabled and all required configuration is provided.
"""
- programs_config = self.create_config(enabled=False)
+ programs_config = self.create_programs_config(enabled=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
- programs_config = self.create_config(enable_student_dashboard=False)
+ programs_config = self.create_programs_config(enable_student_dashboard=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
- programs_config = self.create_config()
+ programs_config = self.create_programs_config()
self.assertTrue(programs_config.is_student_dashboard_enabled)
def test_is_studio_tab_enabled(self, _mock_cache):
@@ -65,16 +65,16 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
Verify that the property controlling display of the Studio tab is only True
when configuration is enabled and all required configuration is provided.
"""
- programs_config = self.create_config(enabled=False)
+ programs_config = self.create_programs_config(enabled=False)
self.assertFalse(programs_config.is_studio_tab_enabled)
- programs_config = self.create_config(enable_studio_tab=False)
+ programs_config = self.create_programs_config(enable_studio_tab=False)
self.assertFalse(programs_config.is_studio_tab_enabled)
- programs_config = self.create_config(authoring_app_js_path='', authoring_app_css_path='')
+ programs_config = self.create_programs_config(authoring_app_js_path='', authoring_app_css_path='')
self.assertFalse(programs_config.is_studio_tab_enabled)
- programs_config = self.create_config()
+ programs_config = self.create_programs_config()
self.assertTrue(programs_config.is_studio_tab_enabled)
def test_is_certification_enabled(self, _mock_cache):
@@ -82,11 +82,11 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
Verify that the property controlling certification-related functionality
for Programs behaves as expected.
"""
- programs_config = self.create_config(enabled=False)
+ programs_config = self.create_programs_config(enabled=False)
self.assertFalse(programs_config.is_certification_enabled)
- programs_config = self.create_config(enable_certification=False)
+ programs_config = self.create_programs_config(enable_certification=False)
self.assertFalse(programs_config.is_certification_enabled)
- programs_config = self.create_config()
+ programs_config = self.create_programs_config()
self.assertTrue(programs_config.is_certification_enabled)
diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py
index cf3468ba4f..b37d14a6cd 100644
--- a/openedx/core/djangoapps/programs/tests/test_utils.py
+++ b/openedx/core/djangoapps/programs/tests/test_utils.py
@@ -6,13 +6,17 @@ import mock
from oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
+from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
-from openedx.core.djangoapps.programs.utils import get_programs, get_programs_for_dashboard
+from openedx.core.djangoapps.programs.utils import (
+ get_programs, get_programs_for_credentials, get_programs_for_dashboard
+)
from student.tests.factories import UserFactory
-class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
+class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
+ CredentialsApiConfigMixin, TestCase):
"""Tests covering the retrieval of programs from the Programs service."""
def setUp(self):
super(TestProgramRetrieval, self).setUp()
@@ -25,7 +29,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@httpretty.activate
def test_get_programs(self):
"""Verify programs data can be retrieved."""
- self.create_config()
+ self.create_programs_config()
self.mock_programs_api()
actual = get_programs(self.user)
@@ -40,7 +44,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@httpretty.activate
def test_get_programs_caching(self):
"""Verify that when enabled, the cache is used for non-staff users."""
- self.create_config(cache_ttl=1)
+ self.create_programs_config(cache_ttl=1)
self.mock_programs_api()
# Warm up the cache.
@@ -63,7 +67,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
def test_get_programs_programs_disabled(self):
"""Verify behavior when programs is disabled."""
- self.create_config(enabled=False)
+ self.create_programs_config(enabled=False)
actual = get_programs(self.user)
self.assertEqual(actual, [])
@@ -71,7 +75,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
def test_get_programs_client_initialization_failure(self, mock_init):
"""Verify behavior when API client fails to initialize."""
- self.create_config()
+ self.create_programs_config()
mock_init.side_effect = Exception
actual = get_programs(self.user)
@@ -81,7 +85,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@httpretty.activate
def test_get_programs_data_retrieval_failure(self):
"""Verify behavior when data can't be retrieved from Programs."""
- self.create_config()
+ self.create_programs_config()
self.mock_programs_api(status_code=500)
actual = get_programs(self.user)
@@ -90,7 +94,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@httpretty.activate
def test_get_programs_for_dashboard(self):
"""Verify programs data can be retrieved and parsed correctly."""
- self.create_config()
+ self.create_programs_config()
self.mock_programs_api()
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
@@ -105,7 +109,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
def test_get_programs_for_dashboard_dashboard_display_disabled(self):
"""Verify behavior when student dashboard display is disabled."""
- self.create_config(enable_student_dashboard=False)
+ self.create_programs_config(enable_student_dashboard=False)
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
@@ -113,7 +117,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@httpretty.activate
def test_get_programs_for_dashboard_no_data(self):
"""Verify behavior when no programs data is found for the user."""
- self.create_config()
+ self.create_programs_config()
self.mock_programs_api(data={'results': []})
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
@@ -122,10 +126,56 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
@httpretty.activate
def test_get_programs_for_dashboard_invalid_data(self):
"""Verify behavior when the Programs API returns invalid data and parsing fails."""
- self.create_config()
-
+ self.create_programs_config()
invalid_program = {'invalid_key': 'invalid_data'}
self.mock_programs_api(data={'results': [invalid_program]})
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
+
+ @httpretty.activate
+ def test_get_program_for_certificates(self):
+ """Verify programs data can be retrieved and parsed correctly for certificates."""
+ self.create_programs_config()
+ self.mock_programs_api()
+
+ actual = get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA)
+ expected = self.PROGRAMS_API_RESPONSE['results']
+ expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url']
+ expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url']
+
+ self.assertEqual(len(actual), 2)
+ self.assertEqual(actual, expected)
+
+ @httpretty.activate
+ def test_get_program_for_certificates_no_data(self):
+ """Verify behavior when no programs data is found for the user."""
+ self.create_programs_config()
+ self.create_credentials_config()
+ self.mock_programs_api(data={'results': []})
+
+ actual = get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA)
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_program_for_certificates_id_not_exist(self):
+ """Verify behavior when no program with the given program_id in
+ credentials exists.
+ """
+ self.create_programs_config()
+ self.create_credentials_config()
+ self.mock_programs_api()
+ credential_data = [
+ {
+ "id": 1,
+ "username": "test",
+ "credential": {
+ "credential_id": 1,
+ "program_id": 100
+ },
+ "status": "awarded",
+ "credential_url": "www.example.com"
+ }
+ ]
+ actual = get_programs_for_credentials(self.user, credential_data)
+ self.assertEqual(actual, [])
diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py
index ecc903411d..4a52401dce 100644
--- a/openedx/core/djangoapps/programs/utils.py
+++ b/openedx/core/djangoapps/programs/utils.py
@@ -1,11 +1,8 @@
"""Helper functions for working with Programs."""
import logging
-from django.core.cache import cache
-from edx_rest_api_client.client import EdxRestApiClient
-
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
-from openedx.core.lib.token_utils import get_id_token
+from openedx.core.lib.edx_api_utils import get_edx_api_data
log = logging.getLogger(__name__)
@@ -13,7 +10,6 @@ log = logging.getLogger(__name__)
def get_programs(user):
"""Given a user, get programs from the Programs service.
-
Returned value is cached depending on user permissions. Staff users making requests
against Programs will receive unpublished programs, while regular users will only receive
published programs.
@@ -25,39 +21,12 @@ def get_programs(user):
list of dict, representing programs returned by the Programs service.
"""
programs_config = ProgramsApiConfig.current()
- no_programs = []
- # Bypass caching for staff users, who may be creating Programs and want to see them displayed immediately.
- use_cache = programs_config.is_cache_enabled and not user.is_staff
+ # Bypass caching for staff users, who may be creating Programs and want
+ # to see them displayed immediately.
+ cache_key = programs_config.CACHE_KEY if programs_config.is_cache_enabled and not user.is_staff else None
- if not programs_config.enabled:
- log.warning('Programs configuration is disabled.')
- return no_programs
-
- if use_cache:
- cached = cache.get(programs_config.CACHE_KEY)
- if cached is not None:
- return cached
-
- try:
- jwt = get_id_token(user, programs_config.OAUTH2_CLIENT_NAME)
- api = EdxRestApiClient(programs_config.internal_api_url, jwt=jwt)
- except Exception: # pylint: disable=broad-except
- log.exception('Failed to initialize the Programs API client.')
- return no_programs
-
- try:
- response = api.programs.get()
- except Exception: # pylint: disable=broad-except
- log.exception('Failed to retrieve programs from the Programs API.')
- return no_programs
-
- results = response.get('results', no_programs)
-
- if use_cache:
- cache.set(programs_config.CACHE_KEY, results, programs_config.cache_ttl)
-
- return results
+ return get_edx_api_data(programs_config, user, 'programs', cache_key=cache_key)
def get_programs_for_dashboard(user, course_keys):
@@ -105,3 +74,31 @@ def get_programs_for_dashboard(user, course_keys):
log.exception('Unable to parse Programs API response: %r', program)
return course_programs
+
+
+def get_programs_for_credentials(user, programs_credentials):
+ """ Given a user and an iterable of credentials, get corresponding programs
+ data and return it as a list of dictionaries.
+
+ Arguments:
+ user (User): The user to authenticate as for requesting programs.
+ programs_credentials (list): List of credentials awarded to the user
+ for completion of a program.
+
+ Returns:
+ list, containing programs dictionaries.
+ """
+ certificate_programs = []
+
+ programs = get_programs(user)
+ if not programs:
+ log.debug('No programs for user %d.', user.id)
+ return certificate_programs
+
+ for program in programs:
+ for credential in programs_credentials:
+ if program['id'] == credential['credential']['program_id']:
+ program['credential_url'] = credential['certificate_url']
+ certificate_programs.append(program)
+
+ return certificate_programs
diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py
index d6e5d44b15..0df064340a 100644
--- a/openedx/core/djangoapps/user_api/accounts/views.py
+++ b/openedx/core/djangoapps/user_api/accounts/views.py
@@ -5,10 +5,10 @@ For more information, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API
"""
from django.db import transaction
-from rest_framework.views import APIView
+from rest_framework import status, permissions
+from rest_framework_jwt.authentication import JSONWebTokenAuthentication
from rest_framework.response import Response
-from rest_framework import status
-from rest_framework import permissions
+from rest_framework.views import APIView
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
@@ -137,7 +137,9 @@ class AccountView(APIView):
If the update is successful, updated user account data is returned.
"""
- authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
+ authentication_classes = (
+ OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser, JSONWebTokenAuthentication
+ )
permission_classes = (permissions.IsAuthenticated,)
parser_classes = (MergePatchParser,)
diff --git a/openedx/core/lib/api/authentication.py b/openedx/core/lib/api/authentication.py
index c20616aaf9..8e773e2f84 100644
--- a/openedx/core/lib/api/authentication.py
+++ b/openedx/core/lib/api/authentication.py
@@ -1,14 +1,15 @@
-"""
-Common Authentication Handlers used across projects.
-"""
+""" Common Authentication Handlers used across projects. """
+import logging
from rest_framework.authentication import SessionAuthentication
from rest_framework import exceptions as drf_exceptions
from rest_framework_oauth.authentication import OAuth2Authentication
-from .exceptions import AuthenticationFailed
from rest_framework_oauth.compat import oauth2_provider, provider_now
+from openedx.core.lib.api.exceptions import AuthenticationFailed
+
+
OAUTH2_TOKEN_ERROR = u'token_error'
OAUTH2_TOKEN_ERROR_EXPIRED = u'token_expired'
OAUTH2_TOKEN_ERROR_MALFORMED = u'token_malformed'
@@ -16,6 +17,9 @@ OAUTH2_TOKEN_ERROR_NONEXISTENT = u'token_nonexistent'
OAUTH2_TOKEN_ERROR_NOT_PROVIDED = u'token_not_provided'
+log = logging.getLogger(__name__)
+
+
class SessionAuthenticationAllowInactiveUser(SessionAuthentication):
"""Ensure that the user is logged in, but do not require the account to be active.
diff --git a/openedx/core/lib/api/jwt_decode_handler.py b/openedx/core/lib/api/jwt_decode_handler.py
new file mode 100644
index 0000000000..dac5fce010
--- /dev/null
+++ b/openedx/core/lib/api/jwt_decode_handler.py
@@ -0,0 +1,43 @@
+"""
+Custom JWT decoding function for django_rest_framework jwt package.
+
+Adds logging to facilitate debugging of InvalidTokenErrors. Also
+requires "exp" and "iat" claims to be present - the base package
+doesn't expose settings to enforce this.
+"""
+import logging
+
+import jwt
+from rest_framework_jwt.settings import api_settings
+
+
+log = logging.getLogger(__name__)
+
+
+def decode(token):
+ """
+ Ensure InvalidTokenErrors are logged for diagnostic purposes, before
+ failing authentication.
+ """
+
+ options = {
+ 'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
+ 'require_exp': True,
+ 'require_iat': True,
+ }
+
+ try:
+ return jwt.decode(
+ token,
+ api_settings.JWT_SECRET_KEY,
+ api_settings.JWT_VERIFY,
+ options=options,
+ leeway=api_settings.JWT_LEEWAY,
+ audience=api_settings.JWT_AUDIENCE,
+ issuer=api_settings.JWT_ISSUER,
+ algorithms=[api_settings.JWT_ALGORITHM]
+ )
+ except jwt.InvalidTokenError as exc:
+ exc_type = u'{}.{}'.format(exc.__class__.__module__, exc.__class__.__name__)
+ log.exception("raised_invalid_token: exc_type=%r, exc_detail=%r", exc_type, exc.message)
+ raise
diff --git a/openedx/core/lib/api/tests/test_authentication.py b/openedx/core/lib/api/tests/test_authentication.py
index b02cef4ea6..63a7aedc84 100644
--- a/openedx/core/lib/api/tests/test_authentication.py
+++ b/openedx/core/lib/api/tests/test_authentication.py
@@ -10,7 +10,6 @@ import itertools
import json
import ddt
-
from django.conf.urls import patterns, url, include
from django.contrib.auth.models import User
from django.http import HttpResponse
@@ -26,8 +25,8 @@ from rest_framework.test import APIRequestFactory, APIClient
from rest_framework.views import APIView
from provider import scope, constants
+from openedx.core.lib.api import authentication
-from .. import authentication
factory = APIRequestFactory() # pylint: disable=invalid-name
diff --git a/openedx/core/lib/edx_api_utils.py b/openedx/core/lib/edx_api_utils.py
new file mode 100644
index 0000000000..ea2407ca1d
--- /dev/null
+++ b/openedx/core/lib/edx_api_utils.py
@@ -0,0 +1,69 @@
+"""Helper functions to get data from APIs"""
+from __future__ import unicode_literals
+import logging
+
+from django.core.cache import cache
+from edx_rest_api_client.client import EdxRestApiClient
+
+from openedx.core.lib.token_utils import get_id_token
+
+
+log = logging.getLogger(__name__)
+
+
+def get_edx_api_data(api_config, user, resource, querystring=None, cache_key=None):
+ """Fetch data from an API using provided API configuration and resource
+ name.
+
+ Arguments:
+ api_config (ConfigurationModel): The configuration model governing
+ interaction with the API.
+ user (User): The user to authenticate as when requesting data.
+ resource(str): Name of the API resource for which data is being
+ requested.
+ querystring(dict): Querystring parameters that might be required to
+ request data.
+ cache_key(str): Where to cache retrieved data. Omitting this will cause the
+ cache to be bypassed.
+
+ Returns:
+ list of dict, representing data returned by the API.
+ """
+ no_data = []
+
+ if not api_config.enabled:
+ log.warning('%s configuration is disabled.', api_config.API_NAME)
+ return no_data
+
+ if cache_key:
+ cached = cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ try:
+ jwt = get_id_token(user, api_config.OAUTH2_CLIENT_NAME)
+ api = EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
+ except Exception: # pylint: disable=broad-except
+ log.exception('Failed to initialize the %s API client.', api_config.API_NAME)
+ return no_data
+
+ try:
+ querystring = {} if not querystring else querystring
+ response = getattr(api, resource).get(**querystring)
+ results = response.get('results', no_data)
+ page = 1
+ next_page = response.get('next', None)
+ while next_page:
+ page += 1
+ querystring['page'] = page
+ response = getattr(api, resource).get(**querystring)
+ results += response.get('results', no_data)
+ next_page = response.get('next', None)
+ except Exception: # pylint: disable=broad-except
+ log.exception('Failed to retrieve data from the %s API.', api_config.API_NAME)
+ return no_data
+
+ if cache_key:
+ cache.set(cache_key, results, api_config.cache_ttl)
+
+ return results
diff --git a/openedx/core/lib/tests/test_edx_api_utils.py b/openedx/core/lib/tests/test_edx_api_utils.py
new file mode 100644
index 0000000000..4362c7b70b
--- /dev/null
+++ b/openedx/core/lib/tests/test_edx_api_utils.py
@@ -0,0 +1,107 @@
+"""Tests covering Api utils."""
+
+from django.core.cache import cache
+from django.test import TestCase
+import httpretty
+import mock
+from oauth2_provider.tests.factories import ClientFactory
+from provider.constants import CONFIDENTIAL
+from testfixtures import LogCapture
+
+from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
+from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
+from openedx.core.lib.edx_api_utils import get_edx_api_data
+from student.tests.factories import UserFactory
+
+
+LOGGER_NAME = 'openedx.core.lib.edx_api_utils'
+
+
+class TestApiDataRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ProgramsApiConfigMixin, ProgramsDataMixin,
+ TestCase):
+ """Test utility for API data retrieval."""
+ def setUp(self):
+ super(TestApiDataRetrieval, self).setUp()
+ ClientFactory(name=CredentialsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
+ ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
+ self.user = UserFactory()
+
+ cache.clear()
+
+ @httpretty.activate
+ def test_get_edx_api_data_programs(self):
+ """Verify programs data can be retrieved using get_edx_api_data."""
+ program_config = self.create_programs_config()
+ self.mock_programs_api()
+
+ actual = get_edx_api_data(program_config, self.user, 'programs')
+ self.assertEqual(
+ actual,
+ self.PROGRAMS_API_RESPONSE['results']
+ )
+
+ # Verify the API was actually hit (not the cache).
+ self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
+
+ def test_get_edx_api_data_disable_config(self):
+ """Verify no data is retrieved if configuration is disabled."""
+ program_config = self.create_programs_config(enabled=False)
+
+ actual = get_edx_api_data(program_config, self.user, 'programs')
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_edx_api_data_cache(self):
+ """Verify that when enabled, the cache is used."""
+ program_config = self.create_programs_config(cache_ttl=1)
+ self.mock_programs_api()
+
+ # Warm up the cache.
+ get_edx_api_data(program_config, self.user, 'programs', cache_key='test.key')
+
+ # Hit the cache.
+ get_edx_api_data(program_config, self.user, 'programs', cache_key='test.key')
+
+ # Verify only one request was made.
+ self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
+
+ @mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
+ def test_get_edx_api_data_client_initialization_failure(self, mock_init):
+ """Verify no data is retrieved and exception logged when API client
+ fails to initialize.
+ """
+ program_config = self.create_programs_config()
+ mock_init.side_effect = Exception
+
+ with LogCapture(LOGGER_NAME) as logger:
+ actual = get_edx_api_data(program_config, self.user, 'programs')
+ logger.check(
+ (LOGGER_NAME, 'ERROR', u'Failed to initialize the programs API client.')
+ )
+ self.assertEqual(actual, [])
+ self.assertTrue(mock_init.called)
+
+ @httpretty.activate
+ def test_get_edx_api_data_retrieval_failure(self):
+ """Verify exception is logged when data can't be retrieved from API."""
+ program_config = self.create_programs_config()
+ self.mock_programs_api(status_code=500)
+ with LogCapture(LOGGER_NAME) as logger:
+ actual = get_edx_api_data(program_config, self.user, 'programs')
+ logger.check(
+ (LOGGER_NAME, 'ERROR', u'Failed to retrieve data from the programs API.')
+ )
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_edx_api_data_multiple_page(self):
+ """Verify that all data is retrieve for multiple page response."""
+ credentials_config = self.create_credentials_config()
+ self.mock_credentials_api(self.user, is_next_page=True)
+ querystring = {'username': self.user.username}
+
+ actual = get_edx_api_data(credentials_config, self.user, 'user_credentials', querystring=querystring)
+ expected_data = self.CREDENTIALS_NEXT_API_RESPONSE['results'] + self.CREDENTIALS_API_RESPONSE['results']
+ self.assertEqual(actual, expected_data)
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index b4a3ae5a55..4ba13655e6 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -32,6 +32,7 @@ django-method-override==0.1.0
#djangorestframework>=3.1,<3.2
git+https://github.com/edx/django-rest-framework.git@3c72cb5ee5baebc4328947371195eae2077197b0#egg=djangorestframework==3.2.3
django==1.8.7
+djangorestframework-jwt==1.7.2
edx-opaque-keys==0.2.1
edx-organizations==0.3.1
edx-rest-api-client==1.2.1
@@ -62,7 +63,7 @@ polib==1.0.3
pycrypto>=2.6
pygments==2.0.1
pygraphviz==1.1
-PyJWT==1.0.1
+PyJWT==1.4.0
pymongo==2.9.1
pyparsing==2.0.1
python-memcached==1.48
@@ -156,6 +157,7 @@ rednose==0.4.3
selenium==2.42.1
splinter==0.5.4
testtools==0.9.34
+testfixtures==4.5.0
# Used for Segment analytics
analytics-python==1.1.0