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:
Renzo Lucioni
2015-11-10 13:25:35 -05:00
committed by jsa
parent c8a791abf0
commit 70d57327eb
39 changed files with 1238 additions and 859 deletions

View File

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

View File

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

View File

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

View File

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

View 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, {})

View File

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

View File

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

View File

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

View File

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

View File

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

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

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