Add Programs tab to Studio
Extends the Programs ConfigurationModel, cleans up Programs-related utilities and corresponding tests, and corrects caching. Uses the Programs API to list programs within Studio. ECOM-2769.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('programs', '0002_programsapiconfig_cache_ttl'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='programsapiconfig',
|
||||
name='authoring_app_css_path',
|
||||
field=models.CharField(max_length=255, verbose_name="Path to authoring app's CSS", blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='programsapiconfig',
|
||||
name='authoring_app_js_path',
|
||||
field=models.CharField(max_length=255, verbose_name="Path to authoring app's JS", blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='programsapiconfig',
|
||||
name='enable_studio_tab',
|
||||
field=models.BooleanField(default=False, verbose_name='Enable Studio Authoring Interface'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='programsapiconfig',
|
||||
name='enable_student_dashboard',
|
||||
field=models.BooleanField(default=False, verbose_name='Enable Student Dashboard Displays'),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +1,46 @@
|
||||
"""
|
||||
Models providing Programs support for the LMS and Studio.
|
||||
"""
|
||||
|
||||
"""Models providing Programs support for the LMS and Studio."""
|
||||
from collections import namedtuple
|
||||
from urlparse import urljoin
|
||||
|
||||
from django.db.models import NullBooleanField, IntegerField, URLField
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
|
||||
|
||||
AuthoringAppConfig = namedtuple('AuthoringAppConfig', ['js_url', 'css_url'])
|
||||
|
||||
|
||||
class ProgramsApiConfig(ConfigurationModel):
|
||||
"""
|
||||
Manages configuration for connecting to the Programs service and using its
|
||||
API.
|
||||
"""
|
||||
OAUTH2_CLIENT_NAME = 'programs'
|
||||
CACHE_KEY = 'programs.api.data'
|
||||
|
||||
api_version_number = models.IntegerField(verbose_name=_("API Version"))
|
||||
|
||||
internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
|
||||
public_service_url = models.URLField(verbose_name=_("Public Service URL"))
|
||||
|
||||
authoring_app_js_path = models.CharField(
|
||||
verbose_name=_("Path to authoring app's JS"),
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"This value is required in order to enable the Studio authoring interface."
|
||||
)
|
||||
)
|
||||
authoring_app_css_path = models.CharField(
|
||||
verbose_name=_("Path to authoring app's CSS"),
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"This value is required in order to enable the Studio authoring interface."
|
||||
)
|
||||
)
|
||||
|
||||
internal_service_url = URLField(verbose_name=_("Internal Service URL"))
|
||||
public_service_url = URLField(verbose_name=_("Public Service URL"))
|
||||
api_version_number = IntegerField(verbose_name=_("API Version"))
|
||||
enable_student_dashboard = NullBooleanField(verbose_name=_("Enable Student Dashboard Displays"))
|
||||
cache_ttl = models.PositiveIntegerField(
|
||||
verbose_name=_("Cache Time To Live"),
|
||||
default=0,
|
||||
@@ -29,31 +49,62 @@ class ProgramsApiConfig(ConfigurationModel):
|
||||
)
|
||||
)
|
||||
|
||||
PROGRAMS_API_CACHE_KEY = "programs.api.data"
|
||||
enable_student_dashboard = models.BooleanField(
|
||||
verbose_name=_("Enable Student Dashboard Displays"),
|
||||
default=False
|
||||
)
|
||||
enable_studio_tab = models.BooleanField(
|
||||
verbose_name=_("Enable Studio Authoring Interface"),
|
||||
default=False
|
||||
)
|
||||
|
||||
@property
|
||||
def internal_api_url(self):
|
||||
"""
|
||||
Generate a URL based on internal service URL and api version number.
|
||||
Generate a URL based on internal service URL and API version number.
|
||||
"""
|
||||
return urljoin(self.internal_service_url, "/api/v{}/".format(self.api_version_number))
|
||||
return urljoin(self.internal_service_url, '/api/v{}/'.format(self.api_version_number))
|
||||
|
||||
@property
|
||||
def public_api_url(self):
|
||||
"""
|
||||
Generate a URL based on public service URL and api version number.
|
||||
Generate a URL based on public service URL and API version number.
|
||||
"""
|
||||
return urljoin(self.public_service_url, "/api/v{}/".format(self.api_version_number))
|
||||
return urljoin(self.public_service_url, '/api/v{}/'.format(self.api_version_number))
|
||||
|
||||
@property
|
||||
def authoring_app_config(self):
|
||||
"""
|
||||
Returns a named tuple containing information required for working with the Programs
|
||||
authoring app, a Backbone app hosted by the Programs service.
|
||||
"""
|
||||
js_url = urljoin(self.public_service_url, self.authoring_app_js_path)
|
||||
css_url = urljoin(self.public_service_url, self.authoring_app_css_path)
|
||||
|
||||
return AuthoringAppConfig(js_url=js_url, css_url=css_url)
|
||||
|
||||
@property
|
||||
def is_cache_enabled(self):
|
||||
"""Whether responses from the Programs API will be cached."""
|
||||
return self.cache_ttl > 0
|
||||
|
||||
@property
|
||||
def is_student_dashboard_enabled(self):
|
||||
"""
|
||||
Indicate whether LMS dashboard functionality related to Programs should
|
||||
Indicates whether LMS dashboard functionality related to Programs should
|
||||
be enabled or not.
|
||||
"""
|
||||
return self.enabled and self.enable_student_dashboard
|
||||
|
||||
@property
|
||||
def is_cache_enabled(self):
|
||||
"""Whether responses from the Programs API will be cached."""
|
||||
return self.enabled and self.cache_ttl > 0
|
||||
def is_studio_tab_enabled(self):
|
||||
"""
|
||||
Indicates whether Studio functionality related to Programs should
|
||||
be enabled or not.
|
||||
"""
|
||||
return (
|
||||
self.enabled and
|
||||
self.enable_studio_tab and
|
||||
bool(self.authoring_app_js_path) and
|
||||
bool(self.authoring_app_css_path)
|
||||
)
|
||||
|
||||
@@ -1,27 +1,199 @@
|
||||
"""
|
||||
Broadly-useful mixins for use in automated tests.
|
||||
"""
|
||||
"""Mixins for use during testing."""
|
||||
import json
|
||||
|
||||
import httpretty
|
||||
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
|
||||
|
||||
class ProgramsApiConfigMixin(object):
|
||||
"""
|
||||
Programs api configuration utility methods for testing.
|
||||
"""
|
||||
"""Utilities for working with Programs configuration during testing."""
|
||||
|
||||
INTERNAL_URL = "http://internal/"
|
||||
PUBLIC_URL = "http://public/"
|
||||
|
||||
DEFAULTS = dict(
|
||||
internal_service_url=INTERNAL_URL,
|
||||
public_service_url=PUBLIC_URL,
|
||||
api_version_number=1,
|
||||
)
|
||||
DEFAULTS = {
|
||||
'enabled': True,
|
||||
'api_version_number': 1,
|
||||
'internal_service_url': 'http://internal.programs.org/',
|
||||
'public_service_url': 'http://public.programs.org/',
|
||||
'authoring_app_js_path': '/path/to/js',
|
||||
'authoring_app_css_path': '/path/to/css',
|
||||
'cache_ttl': 0,
|
||||
'enable_student_dashboard': True,
|
||||
'enable_studio_tab': True,
|
||||
}
|
||||
|
||||
def create_config(self, **kwargs):
|
||||
"""
|
||||
DRY helper. Create a new ProgramsApiConfig with self.DEFAULTS, updated
|
||||
with any kwarg overrides.
|
||||
"""
|
||||
ProgramsApiConfig(**dict(self.DEFAULTS, **kwargs)).save()
|
||||
"""Creates a new ProgramsApiConfig with DEFAULTS, updated with any provided overrides."""
|
||||
fields = dict(self.DEFAULTS, **kwargs)
|
||||
ProgramsApiConfig(**fields).save()
|
||||
|
||||
return ProgramsApiConfig.current()
|
||||
|
||||
|
||||
class ProgramsDataMixin(object):
|
||||
"""Mixin mocking Programs API URLs and providing fake data for testing."""
|
||||
PROGRAM_NAMES = [
|
||||
'Test Program A',
|
||||
'Test Program B',
|
||||
]
|
||||
|
||||
COURSE_KEYS = [
|
||||
'organization-a/course-a/fall',
|
||||
'organization-a/course-a/winter',
|
||||
'organization-a/course-b/fall',
|
||||
'organization-a/course-b/winter',
|
||||
'organization-b/course-c/fall',
|
||||
'organization-b/course-c/winter',
|
||||
'organization-b/course-d/fall',
|
||||
'organization-b/course-d/winter',
|
||||
]
|
||||
|
||||
PROGRAMS_API_RESPONSE = {
|
||||
'results': [
|
||||
{
|
||||
'id': 1,
|
||||
'name': PROGRAM_NAMES[0],
|
||||
'subtitle': 'A program used for testing purposes',
|
||||
'category': 'xseries',
|
||||
'status': 'unpublished',
|
||||
'marketing_slug': '',
|
||||
'organizations': [
|
||||
{
|
||||
'display_name': 'Test Organization A',
|
||||
'key': 'organization-a'
|
||||
}
|
||||
],
|
||||
'course_codes': [
|
||||
{
|
||||
'display_name': 'Test Course A',
|
||||
'key': 'course-a',
|
||||
'organization': {
|
||||
'display_name': 'Test Organization A',
|
||||
'key': 'organization-a'
|
||||
},
|
||||
'run_modes': [
|
||||
{
|
||||
'course_key': COURSE_KEYS[0],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'fall'
|
||||
},
|
||||
{
|
||||
'course_key': COURSE_KEYS[1],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'winter'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'display_name': 'Test Course B',
|
||||
'key': 'course-b',
|
||||
'organization': {
|
||||
'display_name': 'Test Organization A',
|
||||
'key': 'organization-a'
|
||||
},
|
||||
'run_modes': [
|
||||
{
|
||||
'course_key': COURSE_KEYS[2],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'fall'
|
||||
},
|
||||
{
|
||||
'course_key': COURSE_KEYS[3],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'winter'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'created': '2015-10-26T17:52:32.861000Z',
|
||||
'modified': '2015-11-18T22:21:30.826365Z'
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': PROGRAM_NAMES[1],
|
||||
'subtitle': 'Another program used for testing purposes',
|
||||
'category': 'xseries',
|
||||
'status': 'unpublished',
|
||||
'marketing_slug': '',
|
||||
'organizations': [
|
||||
{
|
||||
'display_name': 'Test Organization B',
|
||||
'key': 'organization-b'
|
||||
}
|
||||
],
|
||||
'course_codes': [
|
||||
{
|
||||
'display_name': 'Test Course C',
|
||||
'key': 'course-c',
|
||||
'organization': {
|
||||
'display_name': 'Test Organization B',
|
||||
'key': 'organization-b'
|
||||
},
|
||||
'run_modes': [
|
||||
{
|
||||
'course_key': COURSE_KEYS[4],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'fall'
|
||||
},
|
||||
{
|
||||
'course_key': COURSE_KEYS[5],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'winter'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'display_name': 'Test Course D',
|
||||
'key': 'course-d',
|
||||
'organization': {
|
||||
'display_name': 'Test Organization B',
|
||||
'key': 'organization-b'
|
||||
},
|
||||
'run_modes': [
|
||||
{
|
||||
'course_key': COURSE_KEYS[6],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'fall'
|
||||
},
|
||||
{
|
||||
'course_key': COURSE_KEYS[7],
|
||||
'mode_slug': 'verified',
|
||||
'sku': '',
|
||||
'start_date': '2015-11-05T07:39:02.791741Z',
|
||||
'run_key': 'winter'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'created': '2015-10-26T19:59:03.064000Z',
|
||||
'modified': '2015-10-26T19:59:18.536000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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.')
|
||||
|
||||
url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
|
||||
|
||||
if data is None:
|
||||
data = self.PROGRAMS_API_RESPONSE
|
||||
|
||||
body = json.dumps(data)
|
||||
|
||||
httpretty.reset()
|
||||
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json', status=status_code)
|
||||
|
||||
@@ -1,97 +1,78 @@
|
||||
"""
|
||||
Tests for models supporting Program-related functionality.
|
||||
"""
|
||||
"""Tests for models supporting Program-related functionality."""
|
||||
import ddt
|
||||
from mock import patch
|
||||
from django.test import TestCase
|
||||
import mock
|
||||
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@patch('config_models.models.cache.get', return_value=None) # during tests, make every cache get a miss.
|
||||
class ProgramsApiConfigTest(ProgramsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the ProgramsApiConfig model.
|
||||
"""
|
||||
# ConfigurationModels use the cache. Make every cache get a miss.
|
||||
@mock.patch('config_models.models.cache.get', return_value=None)
|
||||
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()
|
||||
|
||||
def test_default_state(self, _mock_cache):
|
||||
"""
|
||||
Ensure the config stores empty values when no data has been inserted,
|
||||
and is completely disabled.
|
||||
"""
|
||||
self.assertFalse(ProgramsApiConfig.is_enabled())
|
||||
api_config = ProgramsApiConfig.current()
|
||||
self.assertEqual(api_config.internal_service_url, '')
|
||||
self.assertEqual(api_config.public_service_url, '')
|
||||
self.assertEqual(api_config.api_version_number, None)
|
||||
self.assertFalse(api_config.is_student_dashboard_enabled)
|
||||
self.assertEqual(
|
||||
programs_config.internal_api_url,
|
||||
programs_config.internal_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
|
||||
)
|
||||
self.assertEqual(
|
||||
programs_config.public_api_url,
|
||||
programs_config.public_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
|
||||
)
|
||||
|
||||
def test_created_state(self, _mock_cache):
|
||||
"""
|
||||
Ensure the config stores correct values when created with them, but
|
||||
remains disabled.
|
||||
"""
|
||||
self.create_config()
|
||||
self.assertFalse(ProgramsApiConfig.is_enabled())
|
||||
api_config = ProgramsApiConfig.current()
|
||||
self.assertEqual(api_config.internal_service_url, self.INTERNAL_URL)
|
||||
self.assertEqual(api_config.public_service_url, self.PUBLIC_URL)
|
||||
self.assertEqual(api_config.api_version_number, 1)
|
||||
self.assertFalse(api_config.is_student_dashboard_enabled)
|
||||
authoring_app_config = programs_config.authoring_app_config
|
||||
|
||||
def test_api_urls(self, _mock_cache):
|
||||
"""
|
||||
Ensure the api url methods return correct concatenations of service
|
||||
URLs and version numbers.
|
||||
"""
|
||||
self.create_config()
|
||||
api_config = ProgramsApiConfig.current()
|
||||
self.assertEqual(api_config.internal_api_url, "{}api/v1/".format(self.INTERNAL_URL))
|
||||
self.assertEqual(api_config.public_api_url, "{}api/v1/".format(self.PUBLIC_URL))
|
||||
self.assertEqual(
|
||||
authoring_app_config.js_url,
|
||||
programs_config.public_service_url.strip('/') + programs_config.authoring_app_js_path
|
||||
)
|
||||
self.assertEqual(
|
||||
authoring_app_config.css_url,
|
||||
programs_config.public_service_url.strip('/') + programs_config.authoring_app_css_path
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
(0, False),
|
||||
(1, True),
|
||||
)
|
||||
@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)
|
||||
self.assertEqual(programs_config.is_cache_enabled, is_cache_enabled)
|
||||
|
||||
def test_is_student_dashboard_enabled(self, _mock_cache):
|
||||
"""
|
||||
Ensure that is_student_dashboard_enabled only returns True when the
|
||||
current config has both 'enabled' and 'enable_student_dashboard' set to
|
||||
True.
|
||||
Verify that the property controlling display on the student dashboard is only True
|
||||
when configuration is enabled and all required configuration is provided.
|
||||
"""
|
||||
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
|
||||
programs_config = self.create_config(enabled=False)
|
||||
self.assertFalse(programs_config.is_student_dashboard_enabled)
|
||||
|
||||
self.create_config()
|
||||
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
|
||||
programs_config = self.create_config(enable_student_dashboard=False)
|
||||
self.assertFalse(programs_config.is_student_dashboard_enabled)
|
||||
|
||||
self.create_config(enabled=True)
|
||||
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
|
||||
programs_config = self.create_config()
|
||||
self.assertTrue(programs_config.is_student_dashboard_enabled)
|
||||
|
||||
self.create_config(enable_student_dashboard=True)
|
||||
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
|
||||
|
||||
self.create_config(enabled=True, enable_student_dashboard=True)
|
||||
self.assertTrue(ProgramsApiConfig.current().is_student_dashboard_enabled)
|
||||
|
||||
@ddt.data(
|
||||
(True, 0),
|
||||
(False, 0),
|
||||
(False, 1),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_is_cache_enabled_returns_false(self, enabled, cache_ttl, _mock_cache):
|
||||
"""Verify that the method 'is_cache_enabled' returns false if
|
||||
'cache_ttl' value is 0 or config is not enabled.
|
||||
def test_is_studio_tab_enabled(self, _mock_cache):
|
||||
"""
|
||||
self.assertFalse(ProgramsApiConfig.current().is_cache_enabled)
|
||||
|
||||
self.create_config(
|
||||
enabled=enabled,
|
||||
cache_ttl=cache_ttl
|
||||
)
|
||||
self.assertFalse(ProgramsApiConfig.current().is_cache_enabled)
|
||||
|
||||
def test_is_cache_enabled_returns_true(self, _mock_cache):
|
||||
""""Verify that is_cache_enabled returns True when Programs is enabled
|
||||
and the cache TTL is greater than 0."
|
||||
Verify that the property controlling display of the Studio tab is only True
|
||||
when configuration is enabled and all required configuration is provided.
|
||||
"""
|
||||
self.create_config(enabled=True, cache_ttl=10)
|
||||
self.assertTrue(ProgramsApiConfig.current().is_cache_enabled)
|
||||
programs_config = self.create_config(enabled=False)
|
||||
self.assertFalse(programs_config.is_studio_tab_enabled)
|
||||
|
||||
programs_config = self.create_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='')
|
||||
self.assertFalse(programs_config.is_studio_tab_enabled)
|
||||
|
||||
programs_config = self.create_config()
|
||||
self.assertTrue(programs_config.is_studio_tab_enabled)
|
||||
|
||||
131
openedx/core/djangoapps/programs/tests/test_utils.py
Normal file
131
openedx/core/djangoapps/programs/tests/test_utils.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Tests covering Programs utilities."""
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
import httpretty
|
||||
import mock
|
||||
from oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.constants import CONFIDENTIAL
|
||||
|
||||
from openedx.core.djangoapps.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 student.tests.factories import UserFactory
|
||||
|
||||
|
||||
class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
|
||||
"""Tests covering the retrieval of programs from the Programs service."""
|
||||
def setUp(self):
|
||||
super(TestProgramRetrieval, self).setUp()
|
||||
|
||||
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
|
||||
self.user = UserFactory()
|
||||
|
||||
cache.clear()
|
||||
|
||||
@httpretty.activate
|
||||
def test_get_programs(self):
|
||||
"""Verify programs data can be retrieved."""
|
||||
self.create_config()
|
||||
self.mock_programs_api()
|
||||
|
||||
actual = get_programs(self.user)
|
||||
self.assertEqual(
|
||||
actual,
|
||||
self.PROGRAMS_API_RESPONSE['results']
|
||||
)
|
||||
|
||||
# Verify the API was actually hit (not the cache).
|
||||
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
|
||||
|
||||
@httpretty.activate
|
||||
def test_get_programs_caching(self):
|
||||
"""Verify that when enabled, the cache is used for non-staff users."""
|
||||
self.create_config(cache_ttl=1)
|
||||
self.mock_programs_api()
|
||||
|
||||
# Warm up the cache.
|
||||
get_programs(self.user)
|
||||
|
||||
# Hit the cache.
|
||||
get_programs(self.user)
|
||||
|
||||
# Verify only one request was made.
|
||||
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
|
||||
|
||||
staff_user = UserFactory(is_staff=True)
|
||||
|
||||
# Hit the Programs API twice.
|
||||
for _ in range(2):
|
||||
get_programs(staff_user)
|
||||
|
||||
# Verify that three requests have been made (one for student, two for staff).
|
||||
self.assertEqual(len(httpretty.httpretty.latest_requests), 3)
|
||||
|
||||
def test_get_programs_programs_disabled(self):
|
||||
"""Verify behavior when programs is disabled."""
|
||||
self.create_config(enabled=False)
|
||||
|
||||
actual = get_programs(self.user)
|
||||
self.assertEqual(actual, [])
|
||||
|
||||
@mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
|
||||
def test_get_programs_client_initialization_failure(self, mock_init):
|
||||
"""Verify behavior when API client fails to initialize."""
|
||||
self.create_config()
|
||||
mock_init.side_effect = Exception
|
||||
|
||||
actual = get_programs(self.user)
|
||||
self.assertEqual(actual, [])
|
||||
self.assertTrue(mock_init.called)
|
||||
|
||||
@httpretty.activate
|
||||
def test_get_programs_data_retrieval_failure(self):
|
||||
"""Verify behavior when data can't be retrieved from Programs."""
|
||||
self.create_config()
|
||||
self.mock_programs_api(status_code=500)
|
||||
|
||||
actual = get_programs(self.user)
|
||||
self.assertEqual(actual, [])
|
||||
|
||||
@httpretty.activate
|
||||
def test_get_programs_for_dashboard(self):
|
||||
"""Verify programs data can be retrieved and parsed correctly."""
|
||||
self.create_config()
|
||||
self.mock_programs_api()
|
||||
|
||||
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
|
||||
expected = {}
|
||||
for program in self.PROGRAMS_API_RESPONSE['results']:
|
||||
for course_code in program['course_codes']:
|
||||
for run in course_code['run_modes']:
|
||||
course_key = run['course_key']
|
||||
expected[course_key] = program
|
||||
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
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)
|
||||
|
||||
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
|
||||
self.assertEqual(actual, {})
|
||||
|
||||
@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.mock_programs_api(data={'results': []})
|
||||
|
||||
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
|
||||
self.assertEqual(actual, {})
|
||||
|
||||
@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()
|
||||
|
||||
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, {})
|
||||
@@ -1,297 +0,0 @@
|
||||
"""
|
||||
Tests for the Programs.
|
||||
"""
|
||||
from unittest import skipUnless
|
||||
|
||||
import ddt
|
||||
from mock import patch
|
||||
from provider.oauth2.models import Client
|
||||
from provider.constants import CONFIDENTIAL
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from openedx.core.djangoapps.programs.views import get_course_programs_for_dashboard
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
|
||||
from config_models.models import cache
|
||||
|
||||
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@ddt.ddt
|
||||
class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the Programs views.
|
||||
"""
|
||||
|
||||
def setUp(self, **kwargs): # pylint: disable=unused-argument
|
||||
super(TestGetXSeriesPrograms, self).setUp()
|
||||
self.create_config(enabled=True, enable_student_dashboard=True)
|
||||
Client.objects.get_or_create(name="programs", client_type=CONFIDENTIAL)
|
||||
self.user = UserFactory()
|
||||
cache.clear()
|
||||
self.programs_api_response = {
|
||||
"results": [
|
||||
{
|
||||
'category': 'xseries',
|
||||
'status': 'active',
|
||||
'subtitle': 'Dummy program 1 for testing',
|
||||
'name': 'First Program',
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'course_codes': [
|
||||
{
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'display_name': 'Demo XSeries Program 1',
|
||||
'key': 'TEST_A',
|
||||
'run_modes': [
|
||||
{'sku': '', 'mode_slug': 'ABC_1', 'course_key': 'edX/DemoX_1/Run_1'},
|
||||
{'sku': '', 'mode_slug': 'ABC_2', 'course_key': 'edX/DemoX_2/Run_2'},
|
||||
]
|
||||
}
|
||||
],
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-1',
|
||||
},
|
||||
{
|
||||
'category': 'xseries',
|
||||
'status': 'active',
|
||||
'subtitle': 'Dummy program 2 for testing',
|
||||
'name': 'Second Program',
|
||||
'organization': {'display_name': 'Test Organization 2', 'key': 'edX'},
|
||||
'course_codes': [
|
||||
{
|
||||
'organization': {'display_name': 'Test Organization 2', 'key': 'edX'},
|
||||
'display_name': 'Demo XSeries Program 2',
|
||||
'key': 'TEST_B',
|
||||
'run_modes': [
|
||||
{'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'},
|
||||
]
|
||||
}
|
||||
],
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-2',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
self.expected_output = {
|
||||
'edX/DemoX_1/Run_1': {
|
||||
'category': 'xseries',
|
||||
'status': 'active',
|
||||
'subtitle': 'Dummy program 1 for testing',
|
||||
'name': 'First Program',
|
||||
'course_codes': [
|
||||
{
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'display_name': 'Demo XSeries Program 1',
|
||||
'key': 'TEST_A',
|
||||
'run_modes': [
|
||||
{'sku': '', 'mode_slug': 'ABC_1', 'course_key': 'edX/DemoX_1/Run_1'},
|
||||
{'sku': '', 'mode_slug': 'ABC_2', 'course_key': 'edX/DemoX_2/Run_2'},
|
||||
]
|
||||
}
|
||||
],
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-1',
|
||||
},
|
||||
'edX/DemoX_2/Run_2': {
|
||||
'category': 'xseries',
|
||||
'status': 'active',
|
||||
'subtitle': 'Dummy program 1 for testing',
|
||||
'name': 'First Program',
|
||||
'course_codes': [
|
||||
{
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'display_name': 'Demo XSeries Program 1',
|
||||
'key': 'TEST_A',
|
||||
'run_modes': [
|
||||
{'sku': '', 'mode_slug': 'ABC_1', 'course_key': 'edX/DemoX_1/Run_1'},
|
||||
{'sku': '', 'mode_slug': 'ABC_2', 'course_key': 'edX/DemoX_2/Run_2'},
|
||||
]
|
||||
}
|
||||
],
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-1',
|
||||
},
|
||||
}
|
||||
|
||||
self.edx_prg_run = {
|
||||
'category': 'xseries',
|
||||
'status': 'active',
|
||||
'subtitle': 'Dummy program 2 for testing',
|
||||
'name': 'Second Program',
|
||||
'course_codes': [
|
||||
{
|
||||
'organization': {'display_name': 'Test Organization 2', 'key': 'edX'},
|
||||
'display_name': 'Demo XSeries Program 2',
|
||||
'key': 'TEST_B',
|
||||
'run_modes': [
|
||||
{'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'},
|
||||
]
|
||||
}
|
||||
],
|
||||
'organization': {'display_name': 'Test Organization 2', 'key': 'edX'},
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-2',
|
||||
}
|
||||
|
||||
def test_get_course_programs_with_valid_user_and_courses(self):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' returns
|
||||
only matching courses from the xseries programs in the expected format.
|
||||
"""
|
||||
# mock the request call
|
||||
with patch('slumber.Resource.get') as mock_get:
|
||||
mock_get.return_value = self.programs_api_response
|
||||
|
||||
# first test with user having multiple courses in a single xseries
|
||||
programs = get_course_programs_for_dashboard(
|
||||
self.user,
|
||||
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'valid/edX/Course']
|
||||
)
|
||||
|
||||
self.assertTrue(mock_get.called)
|
||||
self.assertEqual(self.expected_output, programs)
|
||||
self.assertEqual(sorted(programs.keys()), ['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2'])
|
||||
|
||||
# now test with user having multiple courses across two different
|
||||
# xseries
|
||||
mock_get.reset_mock()
|
||||
programs = get_course_programs_for_dashboard(
|
||||
self.user,
|
||||
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run', 'valid/edX/Course']
|
||||
)
|
||||
self.expected_output['edX/Program/Program_Run'] = self.edx_prg_run
|
||||
self.assertTrue(mock_get.called)
|
||||
self.assertEqual(self.expected_output, programs)
|
||||
self.assertEqual(
|
||||
sorted(programs.keys()),
|
||||
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run']
|
||||
)
|
||||
|
||||
def test_get_course_programs_with_api_client_exception(self):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' returns
|
||||
empty dictionary in case of an exception coming from patching slumber
|
||||
based client 'programs_api_client'.
|
||||
"""
|
||||
# mock the request call
|
||||
with patch('edx_rest_api_client.client.EdxRestApiClient.__init__') as mock_init:
|
||||
# test output in case of any exception
|
||||
mock_init.side_effect = Exception('exc')
|
||||
programs = get_course_programs_for_dashboard(
|
||||
self.user,
|
||||
['edX/DemoX_1/Run_1', 'valid/edX/Course']
|
||||
)
|
||||
self.assertTrue(mock_init.called)
|
||||
self.assertEqual(programs, {})
|
||||
|
||||
def test_get_course_programs_with_exception(self):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' returns
|
||||
empty dictionary in case of exception while accessing programs service.
|
||||
"""
|
||||
# mock the request call
|
||||
with patch('slumber.Resource.get') as mock_get:
|
||||
# test output in case of any exception
|
||||
mock_get.side_effect = Exception('exc')
|
||||
programs = get_course_programs_for_dashboard(
|
||||
self.user,
|
||||
['edX/DemoX_1/Run_1', 'valid/edX/Course']
|
||||
)
|
||||
self.assertTrue(mock_get.called)
|
||||
self.assertEqual(programs, {})
|
||||
|
||||
def test_get_course_programs_with_non_existing_courses(self):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' returns
|
||||
only those program courses which exists in the programs api response.
|
||||
"""
|
||||
# mock the request call
|
||||
with patch('slumber.Resource.get') as mock_get:
|
||||
mock_get.return_value = self.programs_api_response
|
||||
self.assertEqual(
|
||||
get_course_programs_for_dashboard(self.user, ['invalid/edX/Course']), {}
|
||||
)
|
||||
self.assertTrue(mock_get.called)
|
||||
|
||||
def test_get_course_programs_with_empty_response(self):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' returns
|
||||
empty dict if programs rest api client returns empty response.
|
||||
"""
|
||||
# mock the request call
|
||||
with patch('slumber.Resource.get') as mock_get:
|
||||
mock_get.return_value = {}
|
||||
self.assertEqual(
|
||||
get_course_programs_for_dashboard(self.user, ['edX/DemoX/Run']), {}
|
||||
)
|
||||
self.assertTrue(mock_get.called)
|
||||
|
||||
@patch('openedx.core.djangoapps.programs.views.log.exception')
|
||||
def test_get_course_programs_with_invalid_response(self, log_exception):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' logs
|
||||
the exception message if rest api client returns invalid data.
|
||||
"""
|
||||
program = {
|
||||
'category': 'xseries',
|
||||
'status': 'active',
|
||||
'subtitle': 'Dummy program 1 for testing',
|
||||
'name': 'First Program',
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'course_codes': [
|
||||
{
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'display_name': 'Demo XSeries Program 1',
|
||||
'key': 'TEST_A',
|
||||
'run_modes': [
|
||||
{'sku': '', 'mode_slug': 'ABC_2'},
|
||||
]
|
||||
}
|
||||
],
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-1',
|
||||
}
|
||||
invalid_programs_api_response = {"results": [program]}
|
||||
# mock the request call
|
||||
with patch('slumber.Resource.get') as mock_get:
|
||||
mock_get.return_value = invalid_programs_api_response
|
||||
programs = get_course_programs_for_dashboard(self.user, ['edX/DemoX/Run'])
|
||||
log_exception.assert_called_with(
|
||||
'Unable to parse Programs API response: %r',
|
||||
program
|
||||
)
|
||||
self.assertEqual(programs, {})
|
||||
|
||||
@ddt.data(0, 1)
|
||||
def test_get_course_programs_with_cache(self, ttl):
|
||||
""" Test that the method 'get_course_programs_for_dashboard' with
|
||||
cache_ttl greater than 0 saves the programs into cache and does not
|
||||
hit the api again until the cached data expires.
|
||||
"""
|
||||
self.create_config(enabled=True, enable_student_dashboard=True, cache_ttl=ttl)
|
||||
# Mock the request call
|
||||
with patch('slumber.Resource.get') as mock_get:
|
||||
mock_get.return_value = self.programs_api_response
|
||||
|
||||
# First test with user having multiple courses in a single xseries
|
||||
programs = get_course_programs_for_dashboard(
|
||||
self.user,
|
||||
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'valid/edX/Course']
|
||||
)
|
||||
|
||||
self.assertTrue(mock_get.called)
|
||||
self.assertEqual(self.expected_output, programs)
|
||||
self.assertEqual(sorted(programs.keys()), ['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2'])
|
||||
|
||||
# Now test with user having multiple courses across two different
|
||||
# xseries
|
||||
mock_get.reset_mock()
|
||||
programs = get_course_programs_for_dashboard(
|
||||
self.user,
|
||||
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run', 'valid/edX/Course']
|
||||
)
|
||||
self.expected_output['edX/Program/Program_Run'] = self.edx_prg_run
|
||||
# If cache_ttl value is 0 than cache will be considered as disabled.
|
||||
# And mocked method will be call again
|
||||
if ttl == 0:
|
||||
self.assertTrue(mock_get.called)
|
||||
else:
|
||||
self.assertFalse(mock_get.called)
|
||||
self.assertEqual(self.expected_output, programs)
|
||||
self.assertEqual(
|
||||
sorted(programs.keys()),
|
||||
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run']
|
||||
)
|
||||
@@ -1,49 +1,107 @@
|
||||
"""
|
||||
Helper methods for Programs.
|
||||
"""
|
||||
"""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
|
||||
|
||||
|
||||
def is_student_dashboard_programs_enabled(): # pylint: disable=invalid-name
|
||||
""" Returns a Boolean indicating whether LMS dashboard functionality
|
||||
related to Programs should be enabled or not.
|
||||
"""
|
||||
return ProgramsApiConfig.current().is_student_dashboard_enabled
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def programs_api_client(api_url, jwt_access_token):
|
||||
""" Returns an Programs API client setup with authentication for the
|
||||
specified user.
|
||||
"""
|
||||
return EdxRestApiClient(
|
||||
api_url,
|
||||
jwt=jwt_access_token
|
||||
)
|
||||
def get_programs(user):
|
||||
"""Given a user, get programs from the Programs service.
|
||||
|
||||
|
||||
def is_cache_enabled_for_programs():
|
||||
"""Returns a Boolean indicating whether responses from the Programs API
|
||||
will be cached.
|
||||
"""
|
||||
return ProgramsApiConfig.current().is_cache_enabled
|
||||
|
||||
|
||||
def set_cached_programs_response(programs_data):
|
||||
""" Set cache value for the programs data with specific ttl.
|
||||
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.
|
||||
|
||||
Arguments:
|
||||
programs_data (dict): Programs data in dictionary format
|
||||
user (User): The user to authenticate as when requesting programs.
|
||||
|
||||
Returns:
|
||||
list of dict, representing programs returned by the Programs service.
|
||||
"""
|
||||
cache.set(
|
||||
ProgramsApiConfig.PROGRAMS_API_CACHE_KEY,
|
||||
programs_data,
|
||||
ProgramsApiConfig.current().cache_ttl
|
||||
)
|
||||
programs_config = ProgramsApiConfig.current()
|
||||
no_programs = []
|
||||
|
||||
# Bypass caching for staff users, who may be creating Programs and want to see them displayed immediately.
|
||||
use_cache = programs_config.is_cache_enabled and not user.is_staff
|
||||
|
||||
if not programs_config.enabled:
|
||||
log.warning('Programs configuration is disabled.')
|
||||
return no_programs
|
||||
|
||||
if use_cache:
|
||||
cached = cache.get(programs_config.CACHE_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
jwt = get_id_token(user, programs_config.OAUTH2_CLIENT_NAME)
|
||||
api = EdxRestApiClient(programs_config.internal_api_url, jwt=jwt)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Failed to initialize the Programs API client.')
|
||||
return no_programs
|
||||
|
||||
try:
|
||||
response = api.programs.get()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Failed to retrieve programs from the Programs API.')
|
||||
return no_programs
|
||||
|
||||
results = response.get('results', no_programs)
|
||||
|
||||
if use_cache:
|
||||
cache.set(programs_config.CACHE_KEY, results, programs_config.cache_ttl)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_cached_programs_response():
|
||||
""" Get programs data from cache against cache key."""
|
||||
cache_key = ProgramsApiConfig.PROGRAMS_API_CACHE_KEY
|
||||
return cache.get(cache_key)
|
||||
def get_programs_for_dashboard(user, course_keys):
|
||||
"""Build a dictionary of programs, keyed by course.
|
||||
|
||||
Given a user and an iterable of course keys, find all the programs relevant
|
||||
to the user's dashboard and return them in a dictionary keyed by course key.
|
||||
|
||||
Arguments:
|
||||
user (User): The user to authenticate as when requesting programs.
|
||||
course_keys (list): List of course keys representing the courses in which
|
||||
the given user has active enrollments.
|
||||
|
||||
Returns:
|
||||
dict, containing programs keyed by course. Empty if programs cannot be retrieved.
|
||||
"""
|
||||
programs_config = ProgramsApiConfig.current()
|
||||
course_programs = {}
|
||||
|
||||
if not programs_config.is_student_dashboard_enabled:
|
||||
log.debug('Display of programs on the student dashboard is disabled.')
|
||||
return course_programs
|
||||
|
||||
programs = get_programs(user)
|
||||
if not programs:
|
||||
log.debug('No programs found for the user with ID %d.', user.id)
|
||||
return course_programs
|
||||
|
||||
# Convert course keys to Unicode representation for efficient lookup.
|
||||
course_keys = map(unicode, course_keys)
|
||||
|
||||
# Reindex the result returned by the Programs API from:
|
||||
# program -> course code -> course run
|
||||
# to:
|
||||
# course run -> program
|
||||
# Ignore course runs not present in the user's active enrollments.
|
||||
for program in programs:
|
||||
try:
|
||||
for course_code in program['course_codes']:
|
||||
for run in course_code['run_modes']:
|
||||
course_key = run['course_key']
|
||||
if course_key in course_keys:
|
||||
course_programs[course_key] = program
|
||||
except KeyError:
|
||||
log.exception('Unable to parse Programs API response: %r', program)
|
||||
|
||||
return course_programs
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Main views and method related to the Programs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from openedx.core.djangoapps.util.helpers import get_id_token
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.utils import (
|
||||
programs_api_client,
|
||||
is_student_dashboard_programs_enabled,
|
||||
is_cache_enabled_for_programs,
|
||||
get_cached_programs_response,
|
||||
set_cached_programs_response,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
# OAuth2 Client name for programs
|
||||
CLIENT_NAME = "programs"
|
||||
|
||||
|
||||
def get_course_programs_for_dashboard(user, course_keys): # pylint: disable=invalid-name
|
||||
""" Return all programs related to a user.
|
||||
|
||||
Given a user and an iterable of course keys, find all
|
||||
the programs relevant to the user's dashboard and return them in a
|
||||
dictionary keyed by the course_key.
|
||||
|
||||
Arguments:
|
||||
user (user object): Currently logged-in User for which we need to get
|
||||
JWT ID-Token
|
||||
course_keys (list): List of course keys in which user is enrolled
|
||||
|
||||
Returns:
|
||||
Dictionary response containing programs or None
|
||||
"""
|
||||
course_programs = {}
|
||||
if not is_student_dashboard_programs_enabled():
|
||||
log.warning("Programs service for student dashboard is disabled.")
|
||||
return course_programs
|
||||
|
||||
# unicode-ify the course keys for efficient lookup
|
||||
course_keys = map(unicode, course_keys)
|
||||
|
||||
# If cache config is enabled then get the response from cache first.
|
||||
if is_cache_enabled_for_programs():
|
||||
cached_programs = get_cached_programs_response()
|
||||
if cached_programs is not None:
|
||||
return _get_user_course_programs(cached_programs, course_keys)
|
||||
|
||||
# get programs slumber-based client 'EdxRestApiClient'
|
||||
try:
|
||||
api_client = programs_api_client(ProgramsApiConfig.current().internal_api_url, get_id_token(user, CLIENT_NAME))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Failed to initialize the Programs API client.')
|
||||
return course_programs
|
||||
|
||||
# get programs from api client
|
||||
try:
|
||||
response = api_client.programs.get()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Failed to retrieve programs from the Programs API.')
|
||||
return course_programs
|
||||
|
||||
programs = response.get('results', [])
|
||||
if not programs:
|
||||
log.warning("No programs found for the user '%s'.", user.id)
|
||||
return course_programs
|
||||
|
||||
# If cache config is enabled than set the cache.
|
||||
if is_cache_enabled_for_programs():
|
||||
set_cached_programs_response(programs)
|
||||
|
||||
return _get_user_course_programs(programs, course_keys)
|
||||
|
||||
|
||||
def _get_user_course_programs(programs, users_enrolled_course_keys):
|
||||
""" Parse the raw programs according to the users enrolled courses and
|
||||
return the matched course runs.
|
||||
|
||||
Arguments:
|
||||
programs (list): List containing the programs data.
|
||||
users_enrolled_course_keys (list) : List of course keys in which the user is enrolled.
|
||||
"""
|
||||
|
||||
# reindex the result from pgm -> course code -> course run
|
||||
# to
|
||||
# course run -> program, ignoring course runs not present in the dashboard enrollments
|
||||
course_programs = {}
|
||||
for program in programs:
|
||||
try:
|
||||
for course_code in program['course_codes']:
|
||||
for run in course_code['run_modes']:
|
||||
if run['course_key'] in users_enrolled_course_keys:
|
||||
course_programs[run['course_key']] = program
|
||||
except KeyError:
|
||||
log.exception('Unable to parse Programs API response: %r', program)
|
||||
|
||||
return course_programs
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Common helpers methods for django apps.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from provider.oauth2.models import AccessToken, Client
|
||||
from provider.utils import now
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_id_token(user, client_name):
|
||||
"""Generates a JWT ID-Token, using or creating user's OAuth access token.
|
||||
|
||||
Arguments:
|
||||
user (User Object): User for which we need to get JWT ID-Token
|
||||
client_name (unicode): Name of the OAuth2 Client
|
||||
|
||||
Returns:
|
||||
String containing the signed JWT value or raise the exception
|
||||
'ImproperlyConfigured'
|
||||
"""
|
||||
# TODO: there's a circular import problem somewhere which is why we do the oidc import inside of this function.
|
||||
import oauth2_provider.oidc as oidc
|
||||
|
||||
try:
|
||||
client = Client.objects.get(name=client_name)
|
||||
except Client.DoesNotExist:
|
||||
raise ImproperlyConfigured("OAuth2 Client with name '%s' is not present in the DB" % client_name)
|
||||
|
||||
access_tokens = AccessToken.objects.filter(
|
||||
client=client,
|
||||
user__username=user.username,
|
||||
expires__gt=now()
|
||||
).order_by('-expires')
|
||||
|
||||
if access_tokens:
|
||||
access_token = access_tokens[0]
|
||||
else:
|
||||
access_token = AccessToken.objects.create(client=client, user=user)
|
||||
|
||||
id_token = oidc.id_token(access_token)
|
||||
secret = id_token.access_token.client.client_secret
|
||||
return id_token.encode(secret)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Tests for the helper methods.
|
||||
"""
|
||||
|
||||
import jwt
|
||||
from oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.oauth2.models import AccessToken, Client
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from openedx.core.djangoapps.util.helpers import get_id_token
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class GetIdTokenTest(TestCase):
|
||||
"""
|
||||
Tests for then helper method 'get_id_token'.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client_name = "edx-dummy-client"
|
||||
ClientFactory(name=self.client_name)
|
||||
super(GetIdTokenTest, self).setUp()
|
||||
self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
|
||||
def test_get_id_token(self):
|
||||
"""
|
||||
Test generation of ID Token.
|
||||
"""
|
||||
# test that a user with no ID Token gets a valid token on calling the
|
||||
# method 'get_id_token' against a client
|
||||
self.assertEqual(AccessToken.objects.all().count(), 0)
|
||||
client = Client.objects.get(name=self.client_name)
|
||||
first_token = get_id_token(self.user, self.client_name)
|
||||
self.assertEqual(AccessToken.objects.all().count(), 1)
|
||||
jwt.decode(first_token, client.client_secret, audience=client.client_id)
|
||||
|
||||
# test that a user with existing ID Token gets the same token instead
|
||||
# of a new generated token
|
||||
second_token = get_id_token(self.user, self.client_name)
|
||||
self.assertEqual(AccessToken.objects.all().count(), 1)
|
||||
self.assertEqual(first_token, second_token)
|
||||
64
openedx/core/lib/tests/test_token_utils.py
Normal file
64
openedx/core/lib/tests/test_token_utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests covering utilities for working with ID tokens."""
|
||||
import calendar
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
import freezegun
|
||||
import jwt
|
||||
from oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.constants import CONFIDENTIAL
|
||||
|
||||
from openedx.core.lib.token_utils import get_id_token
|
||||
from student.tests.factories import UserFactory, UserProfileFactory
|
||||
|
||||
|
||||
class TestIdTokenGeneration(TestCase):
|
||||
"""Tests covering ID token generation."""
|
||||
client_name = 'edx-dummy-client'
|
||||
|
||||
def setUp(self):
|
||||
super(TestIdTokenGeneration, self).setUp()
|
||||
|
||||
self.oauth2_client = ClientFactory(name=self.client_name, client_type=CONFIDENTIAL)
|
||||
self.user = UserFactory()
|
||||
|
||||
# Create a profile for the user
|
||||
self.user_profile = UserProfileFactory(user=self.user)
|
||||
|
||||
@override_settings(OAUTH_OIDC_ISSUER='test-issuer', OAUTH_ID_TOKEN_EXPIRATION=1)
|
||||
@freezegun.freeze_time('2015-01-01 12:00:00')
|
||||
def test_get_id_token(self):
|
||||
"""Verify that ID tokens are signed with the correct secret and generated with the correct claims."""
|
||||
token = get_id_token(self.user, self.client_name)
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.oauth2_client.client_secret,
|
||||
audience=self.oauth2_client.client_id,
|
||||
issuer=settings.OAUTH_OIDC_ISSUER,
|
||||
)
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
expiration = now + datetime.timedelta(seconds=settings.OAUTH_ID_TOKEN_EXPIRATION)
|
||||
|
||||
expected_payload = {
|
||||
'preferred_username': self.user.username,
|
||||
'name': self.user_profile.name,
|
||||
'email': self.user.email,
|
||||
'administrator': self.user.is_staff,
|
||||
'iss': settings.OAUTH_OIDC_ISSUER,
|
||||
'exp': calendar.timegm(expiration.utctimetuple()),
|
||||
'iat': calendar.timegm(now.utctimetuple()),
|
||||
'aud': self.oauth2_client.client_id,
|
||||
'sub': self.user.id, # pylint: disable=no-member
|
||||
}
|
||||
|
||||
self.assertEqual(payload, expected_payload)
|
||||
|
||||
def test_get_id_token_invalid_client(self):
|
||||
"""Verify that ImproperlyConfigured is raised when an invalid client name is provided."""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
get_id_token(self.user, 'does-not-exist')
|
||||
60
openedx/core/lib/token_utils.py
Normal file
60
openedx/core/lib/token_utils.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Utilities for working with ID tokens."""
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
import jwt
|
||||
from provider.oauth2.models import Client
|
||||
|
||||
from student.models import UserProfile
|
||||
|
||||
|
||||
def get_id_token(user, client_name):
|
||||
"""Construct a JWT for use with the named client.
|
||||
|
||||
The JWT is signed with the named client's secret, and includes the following claims:
|
||||
|
||||
preferred_username (str): The user's username. The claim name is borrowed from edx-oauth2-provider.
|
||||
name (str): The user's full name.
|
||||
email (str): The user's email address.
|
||||
administrator (Boolean): Whether the user has staff permissions.
|
||||
iss (str): Registered claim. Identifies the principal that issued the JWT.
|
||||
exp (int): Registered claim. Identifies the expiration time on or after which
|
||||
the JWT must NOT be accepted for processing.
|
||||
iat (int): Registered claim. Identifies the time at which the JWT was issued.
|
||||
aud (str): Registered claim. Identifies the recipients that the JWT is intended for. This implementation
|
||||
uses the named client's ID.
|
||||
sub (int): Registered claim. Identifies the user. This implementation uses the raw user id.
|
||||
|
||||
Arguments:
|
||||
user (User): User for which to generate the JWT.
|
||||
client_name (unicode): Name of the OAuth2 Client for which the token is intended.
|
||||
|
||||
Returns:
|
||||
str: the JWT
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If no OAuth2 Client with the provided name exists.
|
||||
"""
|
||||
try:
|
||||
client = Client.objects.get(name=client_name)
|
||||
except Client.DoesNotExist:
|
||||
raise ImproperlyConfigured('OAuth2 Client with name [%s] does not exist' % client_name)
|
||||
|
||||
user_profile = UserProfile.objects.get(user=user)
|
||||
now = datetime.datetime.utcnow()
|
||||
expires_in = getattr(settings, 'OAUTH_ID_TOKEN_EXPIRATION', 30)
|
||||
|
||||
payload = {
|
||||
'preferred_username': user.username,
|
||||
'name': user_profile.name,
|
||||
'email': user.email,
|
||||
'administrator': user.is_staff,
|
||||
'iss': settings.OAUTH_OIDC_ISSUER,
|
||||
'exp': now + datetime.timedelta(seconds=expires_in),
|
||||
'iat': now,
|
||||
'aud': client.client_id,
|
||||
'sub': user.id,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, client.client_secret)
|
||||
Reference in New Issue
Block a user