Merge pull request #11241 from edx/feature/credentials-phase-1

Feature/credentials phase 1
This commit is contained in:
Ahsan Ulhaq
2016-01-22 18:39:53 +05:00
34 changed files with 1056 additions and 82 deletions

View File

@@ -0,0 +1,6 @@
"""
edX Platform support for credentials.
This package will be used as a wrapper for interacting with the credentials
service.
"""

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

View File

@@ -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,
},
),
]

View File

@@ -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),
]

View 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

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

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

View 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, [])

View 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

View File

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

View File

@@ -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.')

View File

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

View File

@@ -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, [])

View File

@@ -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

View File

@@ -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,)

View File

@@ -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.

View 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

View File

@@ -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

View 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

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