diff --git a/cms/djangoapps/contentstore/views/tests/test_programs.py b/cms/djangoapps/contentstore/views/tests/test_programs.py
index 480a9cab01..2bca59f43c 100644
--- a/cms/djangoapps/contentstore/views/tests/test_programs.py
+++ b/cms/djangoapps/contentstore/views/tests/test_programs.py
@@ -21,6 +21,8 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
+ self.create_programs_config()
+
self.staff = UserFactory(is_staff=True)
self.client.login(username=self.staff.username, password='test')
@@ -29,7 +31,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 +50,6 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule
student = UserFactory(is_staff=False)
self.client.login(username=student.username, password='test')
- self.create_config()
self.mock_programs_api()
response = self.client.get(self.studio_home)
@@ -57,7 +58,6 @@ 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.mock_programs_api(data={'results': []})
@@ -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/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..0d2c051fb5 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('status') == 'active':
+ 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/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/models.py b/openedx/core/djangoapps/credentials/models.py
index 303c1338c6..e952609ebc 100644
--- a/openedx/core/djangoapps/credentials/models.py
+++ b/openedx/core/djangoapps/credentials/models.py
@@ -15,6 +15,9 @@ class CredentialsApiConfig(ConfigurationModel):
Manages configuration for connecting to the Credential service and using its
API.
"""
+ OAUTH2_CLIENT_NAME = 'credentials'
+ API_NAME = 'credentials'
+
internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
public_service_url = models.URLField(verbose_name=_("Public Service URL"))
diff --git a/openedx/core/djangoapps/credentials/tests/mixins.py b/openedx/core/djangoapps/credentials/tests/mixins.py
index 12270870ed..c308eb4de8 100644
--- a/openedx/core/djangoapps/credentials/tests/mixins.py
+++ b/openedx/core/djangoapps/credentials/tests/mixins.py
@@ -1,4 +1,7 @@
"""Mixins for use during testing."""
+import json
+
+import httpretty
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
@@ -6,7 +9,7 @@ from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
class CredentialsApiConfigMixin(object):
""" Utilities for working with Credentials configuration during testing."""
- DEFAULTS = {
+ CREDENTIALS_DEFAULTS = {
'enabled': True,
'internal_service_url': 'http://internal.credentials.org/',
'public_service_url': 'http://public.credentials.org/',
@@ -14,11 +17,139 @@ class CredentialsApiConfigMixin(object):
'enable_studio_authoring': True,
}
- def create_config(self, **kwargs):
+ def create_credentials_config(self, **kwargs):
""" Creates a new CredentialsApiConfig with DEFAULTS, updated with any
provided overrides.
"""
- fields = dict(self.DEFAULTS, **kwargs)
+ 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"
+ },
+ {
+ "id": 2,
+ "username": "test",
+ "credential": {
+ "credential_id": 2,
+ "program_id": 2
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-2"
+ },
+ {
+ "id": 3,
+ "username": "test",
+ "credential": {
+ "credential_id": 3,
+ "program_id": 3
+ },
+ "status": "revoked",
+ "uuid": "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"
+ },
+ {
+ "id": 5,
+ "username": "test",
+ "credential": {
+ "course_id": "edx/test02/2015",
+ "credential_id": 5,
+ "certificate_type": "verified"
+ },
+ "status": "awarded",
+ "uuid": "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"
+ }
+ ]
+ }
+
+ CREDENTIALS_NEXT_API_RESPONSE = {
+ "next": 'next_page_url',
+ "results": [
+ {
+ "id": 7,
+ "username": "test",
+ "credential": {
+ "credential_id": 7,
+ "program_id": 7
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-7"
+ },
+ {
+ "id": 8,
+ "username": "test",
+ "credential": {
+ "credential_id": 8,
+ "program_id": 8
+ },
+ "status": "awarded",
+ "uuid": "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_data = self.CREDENTIALS_NEXT_API_RESPONSE
+ next_page_body = json.dumps(next_page_data)
+ next_page_url = internal_api_url + '/user_credentials/?page=2&username=' + user.username
+ 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
index 76891c62bb..066b7ac434 100644
--- a/openedx/core/djangoapps/credentials/tests/test_models.py
+++ b/openedx/core/djangoapps/credentials/tests/test_models.py
@@ -8,7 +8,7 @@ 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_config()
+ credentials_config = self.create_credentials_config()
self.assertEqual(
credentials_config.internal_api_url,
@@ -23,13 +23,13 @@ class TestCredentialsApiConfig(CredentialsApiConfigMixin, TestCase):
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_config(enabled=False)
+ credentials_config = self.create_credentials_config(enabled=False)
self.assertFalse(credentials_config.is_learner_issuance_enabled)
- credentials_config = self.create_config(enable_learner_issuance=False)
+ credentials_config = self.create_credentials_config(enable_learner_issuance=False)
self.assertFalse(credentials_config.is_learner_issuance_enabled)
- credentials_config = self.create_config()
+ credentials_config = self.create_credentials_config()
self.assertTrue(credentials_config.is_learner_issuance_enabled)
def test_is_studio_authoring_enabled(self):
@@ -37,11 +37,11 @@ class TestCredentialsApiConfig(CredentialsApiConfigMixin, TestCase):
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_config(enabled=False)
+ credentials_config = self.create_credentials_config(enabled=False)
self.assertFalse(credentials_config.is_studio_authoring_enabled)
- credentials_config = self.create_config(enable_studio_authoring=False)
+ credentials_config = self.create_credentials_config(enable_studio_authoring=False)
self.assertFalse(credentials_config.is_studio_authoring_enabled)
- credentials_config = self.create_config()
+ 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..56c458330f
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/tests/test_utils.py
@@ -0,0 +1,88 @@
+"""Tests covering Credentials utilities."""
+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()
+
+ @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'])
+
+ 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_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, [])
+
+ @httpretty.activate
+ def test_get_user_programs_credentials(self):
+ """Verify program credentials data can be retrieved and parsed correctly."""
+ credentials_config = self.create_credentials_config()
+ self.create_programs_config()
+ 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'] = \
+ credentials_config.public_service_url + 'credentials/' + self.PROGRAMS_CREDENTIALS_DATA[0]['uuid']
+ expected[1]['credential_url'] = \
+ credentials_config.public_service_url + 'credentials/' + self.PROGRAMS_CREDENTIALS_DATA[1]['uuid']
+ self.assertEqual(len(actual), 2)
+ self.assertEqual(actual, expected)
+
+ httpretty.reset()
diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py
new file mode 100644
index 0000000000..2a1e46c3cc
--- /dev/null
+++ b/openedx/core/djangoapps/credentials/utils.py
@@ -0,0 +1,61 @@
+"""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.api_utils import get_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}
+ credentials = get_api_data(
+ credential_configuration, user, credential_configuration.API_NAME, 'user_credentials', querystring=user_query
+ )
+ 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..da168b45f6 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,29 @@ class ProgramsDataMixin(object):
]
}
+ PROGRAMS_CREDENTIALS_DATA = [
+ {
+ "id": 1,
+ "username": "test",
+ "credential": {
+ "credential_id": 1,
+ "program_id": 1
+ },
+ "status": "awarded",
+ "uuid": "dummy-uuid-1"
+ },
+ {
+ "id": 2,
+ "username": "test",
+ "credential": {
+ "credential_id": 2,
+ "program_id": 2
+ },
+ "status": "awarded",
+ "uuid": "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..65beb61b56 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):
diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py
index cf3468ba4f..a93e9979da 100644
--- a/openedx/core/djangoapps/programs/tests/test_utils.py
+++ b/openedx/core/djangoapps/programs/tests/test_utils.py
@@ -2,17 +2,20 @@
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 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 +28,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)
@@ -37,60 +40,10 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
# Verify the API was actually hit (not the cache).
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
- @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.mock_programs_api()
-
- # Warm up the cache.
- get_programs(self.user)
-
- # Hit the cache.
- get_programs(self.user)
-
- # Verify only one request was made.
- self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
-
- staff_user = UserFactory(is_staff=True)
-
- # Hit the Programs API twice.
- for _ in range(2):
- get_programs(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_programs_programs_disabled(self):
- """Verify behavior when programs is disabled."""
- self.create_config(enabled=False)
-
- actual = get_programs(self.user)
- self.assertEqual(actual, [])
-
- @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()
- mock_init.side_effect = Exception
-
- actual = get_programs(self.user)
- self.assertEqual(actual, [])
- self.assertTrue(mock_init.called)
-
- @httpretty.activate
- def test_get_programs_data_retrieval_failure(self):
- """Verify behavior when data can't be retrieved from Programs."""
- self.create_config()
- self.mock_programs_api(status_code=500)
-
- actual = get_programs(self.user)
- self.assertEqual(actual, [])
-
@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 +58,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 +66,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 +75,58 @@ 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()
+ credentials_config = self.create_credentials_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'] = \
+ credentials_config.public_service_url + 'credentials/' + self.PROGRAMS_CREDENTIALS_DATA[0]['uuid']
+ expected[1]['credential_url'] = \
+ credentials_config.public_service_url + 'credentials/' + self.PROGRAMS_CREDENTIALS_DATA[1]['uuid']
+ 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..240c65f8da 100644
--- a/openedx/core/djangoapps/programs/utils.py
+++ b/openedx/core/djangoapps/programs/utils.py
@@ -1,11 +1,10 @@
"""Helper functions for working with Programs."""
import logging
+from urlparse import urljoin
-from django.core.cache import cache
-from edx_rest_api_client.client import EdxRestApiClient
-
+from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
-from openedx.core.lib.token_utils import get_id_token
+from openedx.core.lib.api_utils import get_api_data
log = logging.getLogger(__name__)
@@ -13,7 +12,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 +23,10 @@ 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
-
- 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_api_data(programs_config, user, programs_config.API_NAME, 'programs', use_cache=use_cache)
def get_programs_for_dashboard(user, course_keys):
@@ -105,3 +74,34 @@ 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.
+ """
+ ProgramsApiConfig.current()
+ certificate_programs = []
+
+ programs = get_programs(user)
+ if not programs:
+ log.debug('No programs found for the user with ID %d.', user.id)
+ return certificate_programs
+
+ credential_configuration = CredentialsApiConfig.current()
+ for program in programs:
+ for credential in programs_credentials:
+ if program['id'] == credential['credential']['program_id']:
+ credentials_url = 'credentials/' + credential['uuid']
+ program['credential_url'] = urljoin(credential_configuration.public_service_url, credentials_url)
+ certificate_programs.append(program)
+
+ return certificate_programs
diff --git a/openedx/core/lib/api_utils.py b/openedx/core/lib/api_utils.py
new file mode 100644
index 0000000000..a43e643199
--- /dev/null
+++ b/openedx/core/lib/api_utils.py
@@ -0,0 +1,72 @@
+"""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_api_data(api_config, user, api_name, resource, querystring=None, use_cache=False):
+ """Fetch the data from the API using provided API Configuration and
+ resource.
+
+ Arguments:
+ api_config: The configuration which will be user for requesting data.
+ user (User): The user to authenticate as when requesting data.
+ api_name: Name fo the api to be use for logging.
+ resource: API resource to from where data will be requested.
+ querystring: Querystring parameters that might be required to request
+ data.
+ use_cache: Will be used to decide whether to cache the response data
+ or not.
+
+ Returns:
+ list of dict, representing data returned by the API.
+ """
+ no_data = []
+
+ if not api_config.enabled:
+ log.warning('%s configuration is disabled.', api_name)
+ return no_data
+
+ if use_cache:
+ if api_config.CACHE_KEY:
+ cached = cache.get(api_config.CACHE_KEY)
+ if cached is not None:
+ return cached
+ else:
+ log.warning('No cache key available for %s configuration.', api_name)
+ return no_data
+
+ 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_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_name)
+ return no_data
+
+ if use_cache:
+ cache.set(api_config.CACHE_KEY, results, api_config.cache_ttl)
+
+ return results
diff --git a/openedx/core/lib/tests/test_api_utils.py b/openedx/core/lib/tests/test_api_utils.py
new file mode 100644
index 0000000000..c039fee789
--- /dev/null
+++ b/openedx/core/lib/tests/test_api_utils.py
@@ -0,0 +1,114 @@
+"""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 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.api_utils import get_api_data
+from student.tests.factories import UserFactory
+
+
+class TestApiDataRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ProgramsApiConfigMixin, ProgramsDataMixin,
+ TestCase):
+ """Test data retrieval from the api util function."""
+ 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_api_data_programs(self):
+ """Verify programs data can be retrieve using get_api_data."""
+ program_config = self.create_programs_config()
+ self.mock_programs_api()
+
+ actual = get_api_data(program_config, self.user, 'programs', '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)
+
+ @httpretty.activate
+ def test_get_api_data_credentials(self):
+ """Verify credentials data can be retrieve using get_api_data."""
+ credentials_config = self.create_credentials_config()
+ self.mock_credentials_api(self.user)
+ querystring = {'username': self.user.username}
+
+ actual = get_api_data(credentials_config, self.user, 'credentials', 'user_credentials', querystring=querystring)
+ self.assertEqual(
+ actual,
+ self.CREDENTIALS_API_RESPONSE['results']
+ )
+
+ def test_get_api_data_disable_config(self):
+ """Verify no data is retrieve if configuration is disabled."""
+ program_config = self.create_programs_config(enabled=False)
+
+ actual = get_api_data(program_config, self.user, 'programs', 'programs')
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_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_api_data(program_config, self.user, 'programs', 'programs', use_cache=True)
+
+ # Hit the cache.
+ get_api_data(program_config, self.user, 'programs', 'programs', use_cache=True)
+
+ # Verify only one request was made.
+ self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
+
+ def test_get_api_data_without_cache_key(self):
+ """Verify that when cache enabled without cache key then no data is retrieved."""
+ ProgramsApiConfig.CACHE_KEY = None
+ program_config = self.create_programs_config(cache_ttl=1)
+
+ actual = get_api_data(program_config, self.user, 'programs', 'programs', use_cache=True)
+ self.assertEqual(actual, [])
+
+ @mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
+ def test_get_api_data_client_initialization_failure(self, mock_init):
+ """Verify behavior when API client fails to initialize."""
+ program_config = self.create_programs_config()
+ mock_init.side_effect = Exception
+
+ actual = get_api_data(program_config, self.user, 'programs', 'programs')
+ self.assertEqual(actual, [])
+ self.assertTrue(mock_init.called)
+
+ @httpretty.activate
+ def test_get_api_data_retrieval_failure(self):
+ """Verify behavior when data can't be retrieved from API."""
+ program_config = self.create_programs_config()
+ self.mock_programs_api(status_code=500)
+
+ actual = get_api_data(program_config, self.user, 'programs', 'programs')
+ self.assertEqual(actual, [])
+
+ @httpretty.activate
+ def test_get_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_api_data(credentials_config, self.user, 'credentials', 'user_credentials', querystring=querystring)
+ expected_data = self.CREDENTIALS_NEXT_API_RESPONSE['results'] + self.CREDENTIALS_API_RESPONSE['results']
+ self.assertEqual(actual, expected_data)