basic programs api setup and dashboard integration
ECOM-2578
This commit is contained in:
@@ -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.
|
||||
"""
|
||||
|
||||
27
openedx/core/djangoapps/programs/tests/mixins.py
Normal file
27
openedx/core/djangoapps/programs/tests/mixins.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
208
openedx/core/djangoapps/programs/tests/test_views.py
Normal file
208
openedx/core/djangoapps/programs/tests/test_views.py
Normal 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)
|
||||
22
openedx/core/djangoapps/programs/utils.py
Normal file
22
openedx/core/djangoapps/programs/utils.py
Normal 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
|
||||
)
|
||||
68
openedx/core/djangoapps/programs/views.py
Normal file
68
openedx/core/djangoapps/programs/views.py
Normal 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
|
||||
Reference in New Issue
Block a user