Merge pull request #11241 from edx/feature/credentials-phase-1
Feature/credentials phase 1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -839,6 +839,8 @@ INSTALLED_APPS = (
|
||||
# Microsite configuration application
|
||||
'microsite_configuration',
|
||||
|
||||
# Credentials support
|
||||
'openedx.core.djangoapps.credentials',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ DATABASES = {
|
||||
# This hack disables migrations during tests. We want to create tables directly from the models for speed.
|
||||
# See https://groups.google.com/d/msg/django-developers/PWPj3etj3-U/kCl6pMsQYYoJ.
|
||||
MIGRATION_MODULES = {app: "app.migrations_not_used_in_tests" for app in INSTALLED_APPS}
|
||||
MIGRATION_MODULES["credentials"] = "app.migrations_not_used_in_tests"
|
||||
|
||||
LMS_BASE = "localhost:8000"
|
||||
FEATURES['PREVIEW_LMS_BASE'] = "preview"
|
||||
|
||||
@@ -1034,7 +1034,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(
|
||||
@@ -1067,7 +1067,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',
|
||||
@@ -1097,7 +1097,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(
|
||||
@@ -1118,7 +1118,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)]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -607,6 +608,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
|
||||
@@ -730,6 +732,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)
|
||||
@@ -2408,3 +2411,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
|
||||
|
||||
@@ -727,6 +727,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 ##################
|
||||
|
||||
|
||||
@@ -1924,6 +1924,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"
|
||||
@@ -2000,6 +2003,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 = {}
|
||||
@@ -2554,6 +2569,9 @@ ECOMMERCE_API_SIGNING_KEY = None
|
||||
ECOMMERCE_API_TIMEOUT = 5
|
||||
ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker'
|
||||
|
||||
# Credentials configuration
|
||||
CREDENTIALS_SERVICE_USERNAME = 'credentials_service_user'
|
||||
|
||||
# Reverification checkpoint name pattern
|
||||
CHECKPOINT_PATTERN = r'(?P<checkpoint_name>[^/]+)'
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -190,6 +190,7 @@ DATABASES = {
|
||||
# This hack disables migrations during tests. We want to create tables directly from the models for speed.
|
||||
# See https://groups.google.com/d/msg/django-developers/PWPj3etj3-U/kCl6pMsQYYoJ.
|
||||
MIGRATION_MODULES = {app: "app.migrations_not_used_in_tests" for app in INSTALLED_APPS}
|
||||
MIGRATION_MODULES["credentials"] = "app.migrations_not_used_in_tests"
|
||||
|
||||
CACHES = {
|
||||
# This is the cache used for most things.
|
||||
@@ -562,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',
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -180,6 +180,19 @@ import json
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
% if xseries_credentials:
|
||||
<div class="wrapper-xseries-certificates">
|
||||
<p class="title">${_("XSeries Program Certificates")}</p>
|
||||
<p class="copy">${_("You have received a certificate for the following XSeries programs:")}</p>
|
||||
<ul>
|
||||
% for xseries_credential in xseries_credentials:
|
||||
<li>
|
||||
<a class="copy" href="${xseries_credential['credential_url']}">${xseries_credential['display_name']}</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
|
||||
<section id="email-settings-modal" class="modal" aria-hidden="true">
|
||||
|
||||
6
openedx/core/djangoapps/credentials/__init__.py
Normal file
6
openedx/core/djangoapps/credentials/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
edX Platform support for credentials.
|
||||
|
||||
This package will be used as a wrapper for interacting with the credentials
|
||||
service.
|
||||
"""
|
||||
16
openedx/core/djangoapps/credentials/admin.py
Normal file
16
openedx/core/djangoapps/credentials/admin.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
def add_service_user(apps, schema_editor):
|
||||
"""Add service user."""
|
||||
user, created = User.objects.get_or_create(username=settings.CREDENTIALS_SERVICE_USERNAME)
|
||||
if created:
|
||||
user.is_staff = True
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
|
||||
def remove_service_user(apps, schema_editor):
|
||||
"""Remove service user."""
|
||||
try:
|
||||
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME).delete()
|
||||
except User.DoesNotExist:
|
||||
return
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('credentials', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_service_user, remove_service_user),
|
||||
]
|
||||
82
openedx/core/djangoapps/credentials/models.py
Normal file
82
openedx/core/djangoapps/credentials/models.py
Normal file
@@ -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
|
||||
164
openedx/core/djangoapps/credentials/tests/mixins.py
Normal file
164
openedx/core/djangoapps/credentials/tests/mixins.py
Normal file
@@ -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
|
||||
)
|
||||
47
openedx/core/djangoapps/credentials/tests/test_models.py
Normal file
47
openedx/core/djangoapps/credentials/tests/test_models.py
Normal file
@@ -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)
|
||||
117
openedx/core/djangoapps/credentials/tests/test_utils.py
Normal file
117
openedx/core/djangoapps/credentials/tests/test_utils.py
Normal file
@@ -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, [])
|
||||
66
openedx/core/djangoapps/credentials/utils.py
Normal file
66
openedx/core/djangoapps/credentials/utils.py
Normal file
@@ -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
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class ProgramsApiConfigMixin(object):
|
||||
'enable_studio_tab': 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()
|
||||
@@ -184,6 +184,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.')
|
||||
|
||||
@@ -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,14 +65,14 @@ 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)
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -17,7 +17,6 @@ from openedx.core.lib.api.authentication import (
|
||||
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
|
||||
from openedx.core.lib.api.parsers import MergePatchParser
|
||||
from .api import get_account_settings, update_account_settings
|
||||
from .serializers import PROFILE_IMAGE_KEY_PREFIX
|
||||
|
||||
|
||||
class AccountView(APIView):
|
||||
@@ -138,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,)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
43
openedx/core/lib/api/jwt_decode_handler.py
Normal file
43
openedx/core/lib/api/jwt_decode_handler.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
69
openedx/core/lib/edx_api_utils.py
Normal file
69
openedx/core/lib/edx_api_utils.py
Normal file
@@ -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
|
||||
107
openedx/core/lib/tests/test_edx_api_utils.py
Normal file
107
openedx/core/lib/tests/test_edx_api_utils.py
Normal file
@@ -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)
|
||||
@@ -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-rest-api-client==1.2.1
|
||||
edx-search==0.1.1
|
||||
facebook-sdk==0.4.0
|
||||
@@ -59,7 +60,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
|
||||
@@ -152,6 +153,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
|
||||
|
||||
Reference in New Issue
Block a user