basic programs api setup and dashboard integration

ECOM-2578
This commit is contained in:
zubair-arbi
2015-10-22 15:02:51 +05:00
parent 9f89112ee1
commit 7f96474d01
15 changed files with 476 additions and 61 deletions

View File

@@ -0,0 +1,10 @@
"""
Platform support for Programs.
This package is a thin wrapper around interactions with the Programs service,
supporting learner- and author-facing features involving that service
if and only if the service is deployed in the Open edX installation.
To ensure maximum separation of concerns, and a minimum of interdependencies,
this package should be kept small, thin, and stateless.
"""

View File

@@ -0,0 +1,27 @@
"""
Broadly-useful mixins for use in automated tests.
"""
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
class ProgramsApiConfigMixin(object):
"""
Programs api configuration utility methods for 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,
)
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()

View File

@@ -2,34 +2,20 @@
Tests for models supporting Program-related functionality.
"""
from django.test import TestCase
from mock import patch
from django.test import TestCase
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
@patch('config_models.models.cache.get', return_value=None) # during tests, make every cache get a miss.
class ProgramsApiConfigTest(TestCase):
class ProgramsApiConfigTest(ProgramsApiConfigMixin, TestCase):
"""
Tests for the ProgramsApiConfig model.
"""
INTERNAL_URL = "http://internal/"
PUBLIC_URL = "http://public/"
DEFAULTS = dict(
internal_service_url=INTERNAL_URL,
public_service_url=PUBLIC_URL,
api_version_number=1,
)
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()
def test_default_state(self, _mock_cache):
"""
Ensure the config stores empty values when no data has been inserted,

View File

@@ -0,0 +1,208 @@
"""
Tests for the Programs.
"""
from mock import patch
from provider.oauth2.models import Client
from provider.constants import CONFIDENTIAL
from unittest import skipUnless
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
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
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()
self.programs_api_response = {
"results": [
{
'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',
'marketing_slug': 'fake-marketing-slug-xseries-1',
'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'},
]
}
]
},
{
'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',
'marketing_slug': 'fake-marketing-slug-xseries-2',
'run_modes': [
{'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'},
]
}
]
}
]
}
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']
)
expected_output = {
'edX/DemoX_1/Run_1': {
'category': 'xseries',
'status': 'active',
'course_codes': [
{
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-1',
'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'},
]
}
],
'subtitle': 'Dummy program 1 for testing',
'name': 'First Program'
},
'edX/DemoX_2/Run_2': {
'category': 'xseries',
'status': 'active',
'course_codes': [
{
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-1',
'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'},
]
}
],
'subtitle': 'Dummy program 1 for testing',
'name': 'First Program'
},
}
self.assertTrue(mock_get.called)
self.assertEqual(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']
)
expected_output['edX/Program/Program_Run'] = {
'category': 'xseries',
'status': 'active',
'course_codes': [
{
'organization': {'display_name': 'Test Organization 2', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-2',
'display_name': 'Demo XSeries Program 2',
'key': 'TEST_B',
'run_modes': [
{'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'},
]
}
],
'subtitle': 'Dummy program 2 for testing',
'name': 'Second Program'
}
self.assertTrue(mock_get.called)
self.assertEqual(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)

View File

@@ -0,0 +1,22 @@
"""
Helper methods for Programs.
"""
from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
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
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
)

View File

@@ -0,0 +1,68 @@
"""
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
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)
# 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
# reindex the result from pgm -> course code -> course run
# to
# course run -> program, ignoring course runs not present in the dashboard enrollments
for program in programs:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
if run['course_key'] in course_keys:
course_programs[run['course_key']] = program
return course_programs