Retrieve marketable MicroMasters from the catalog service
Catalog-based MicroMasters need to be displayed in the LMS. However, the LMS currently retrieves all program data from the soon-to-be-retired programs service. Consuming program data exclusively from the catalog service is out of the question right now; it's too complex to confidently pull off in a week. This is a functional middle ground introduced by ECOM-5460. Cleaning up this debt is tracked by ECOM-4418.
This commit is contained in:
@@ -1,8 +1,21 @@
|
||||
"""Factories for generating fake catalog data."""
|
||||
from uuid import uuid4
|
||||
|
||||
import factory
|
||||
from factory.fuzzy import FuzzyText
|
||||
|
||||
|
||||
class Organization(factory.Factory):
|
||||
"""
|
||||
Factory for stubbing Organization resources from the catalog API.
|
||||
"""
|
||||
class Meta(object):
|
||||
model = dict
|
||||
|
||||
name = FuzzyText(prefix='Organization ')
|
||||
key = FuzzyText(suffix='X')
|
||||
|
||||
|
||||
class CourseRun(factory.Factory):
|
||||
"""
|
||||
Factory for stubbing CourseRun resources from the catalog API.
|
||||
@@ -12,3 +25,48 @@ class CourseRun(factory.Factory):
|
||||
|
||||
key = FuzzyText(prefix='org/', suffix='/run')
|
||||
marketing_url = FuzzyText(prefix='https://www.example.com/marketing/')
|
||||
|
||||
|
||||
class Course(factory.Factory):
|
||||
"""
|
||||
Factory for stubbing Course resources from the catalog API.
|
||||
"""
|
||||
class Meta(object):
|
||||
model = dict
|
||||
|
||||
title = FuzzyText(prefix='Course ')
|
||||
key = FuzzyText(prefix='course+')
|
||||
owners = [Organization()]
|
||||
course_runs = [CourseRun() for __ in range(3)]
|
||||
|
||||
|
||||
class BannerImage(factory.Factory):
|
||||
"""
|
||||
Factory for stubbing BannerImage resources from the catalog API.
|
||||
"""
|
||||
class Meta(object):
|
||||
model = dict
|
||||
|
||||
url = FuzzyText(
|
||||
prefix='https://www.somecdn.com/media/programs/banner_images/',
|
||||
suffix='.jpg'
|
||||
)
|
||||
|
||||
|
||||
class Program(factory.Factory):
|
||||
"""
|
||||
Factory for stubbing Program resources from the catalog API.
|
||||
"""
|
||||
class Meta(object):
|
||||
model = dict
|
||||
|
||||
uuid = str(uuid4())
|
||||
title = FuzzyText(prefix='Program ')
|
||||
subtitle = FuzzyText(prefix='Subtitle ')
|
||||
type = 'FooBar'
|
||||
marketing_slug = FuzzyText(prefix='slug_')
|
||||
authoring_organizations = [Organization()]
|
||||
courses = [Course() for __ in range(3)]
|
||||
banner_image = {
|
||||
size: BannerImage() for size in ['large', 'medium', 'small', 'x-small']
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests covering utilities for integrating with the catalog service."""
|
||||
import uuid
|
||||
|
||||
import ddt
|
||||
from django.test import TestCase
|
||||
import mock
|
||||
@@ -16,6 +18,139 @@ UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils'
|
||||
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
|
||||
# ConfigurationModels use the cache. Make every cache get a miss.
|
||||
@mock.patch('config_models.models.cache.get', return_value=None)
|
||||
class TestGetPrograms(mixins.CatalogIntegrationMixin, TestCase):
|
||||
"""Tests covering retrieval of programs from the catalog service."""
|
||||
def setUp(self):
|
||||
super(TestGetPrograms, self).setUp()
|
||||
|
||||
self.user = UserFactory()
|
||||
self.uuid = str(uuid.uuid4())
|
||||
self.type = 'FooBar'
|
||||
self.catalog_integration = self.create_catalog_integration(cache_ttl=1)
|
||||
|
||||
def assert_contract(self, call_args, program_uuid=None, type=None): # pylint: disable=redefined-builtin
|
||||
"""Verify that API data retrieval utility is used correctly."""
|
||||
args, kwargs = call_args
|
||||
|
||||
for arg in (self.catalog_integration, self.user, 'programs'):
|
||||
self.assertIn(arg, args)
|
||||
|
||||
self.assertEqual(kwargs['resource_id'], program_uuid)
|
||||
|
||||
cache_key = '{base}.programs{type}'.format(
|
||||
base=self.catalog_integration.CACHE_KEY,
|
||||
type='.' + type if type else ''
|
||||
)
|
||||
self.assertEqual(
|
||||
kwargs['cache_key'],
|
||||
cache_key if self.catalog_integration.is_cache_enabled else None
|
||||
)
|
||||
|
||||
self.assertEqual(kwargs['api']._store['base_url'], self.catalog_integration.internal_api_url) # pylint: disable=protected-access
|
||||
|
||||
querystring = {'marketable': 1}
|
||||
if type:
|
||||
querystring['type'] = type
|
||||
self.assertEqual(kwargs['querystring'], querystring)
|
||||
|
||||
return args, kwargs
|
||||
|
||||
def test_get_programs(self, _mock_cache, mock_get_catalog_data):
|
||||
programs = [factories.Program() for __ in range(3)]
|
||||
mock_get_catalog_data.return_value = programs
|
||||
|
||||
data = utils.get_programs(self.user)
|
||||
|
||||
self.assert_contract(mock_get_catalog_data.call_args)
|
||||
self.assertEqual(data, programs)
|
||||
|
||||
def test_get_one_program(self, _mock_cache, mock_get_catalog_data):
|
||||
program = factories.Program()
|
||||
mock_get_catalog_data.return_value = program
|
||||
|
||||
data = utils.get_programs(self.user, uuid=self.uuid)
|
||||
|
||||
self.assert_contract(mock_get_catalog_data.call_args, program_uuid=self.uuid)
|
||||
self.assertEqual(data, program)
|
||||
|
||||
def test_get_programs_by_type(self, _mock_cache, mock_get_catalog_data):
|
||||
programs = [factories.Program() for __ in range(2)]
|
||||
mock_get_catalog_data.return_value = programs
|
||||
|
||||
data = utils.get_programs(self.user, type=self.type)
|
||||
|
||||
self.assert_contract(mock_get_catalog_data.call_args, type=self.type)
|
||||
self.assertEqual(data, programs)
|
||||
|
||||
def test_programs_unavailable(self, _mock_cache, mock_get_catalog_data):
|
||||
mock_get_catalog_data.return_value = []
|
||||
|
||||
data = utils.get_programs(self.user)
|
||||
|
||||
self.assert_contract(mock_get_catalog_data.call_args)
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def test_cache_disabled(self, _mock_cache, mock_get_catalog_data):
|
||||
self.catalog_integration = self.create_catalog_integration(cache_ttl=0)
|
||||
utils.get_programs(self.user)
|
||||
self.assert_contract(mock_get_catalog_data.call_args)
|
||||
|
||||
def test_config_missing(self, _mock_cache, _mock_get_catalog_data):
|
||||
"""Verify that no errors occur if this method is called when catalog config is missing."""
|
||||
CatalogIntegration.objects.all().delete()
|
||||
|
||||
data = utils.get_programs(self.user)
|
||||
self.assertEqual(data, [])
|
||||
|
||||
|
||||
class TestMungeCatalogProgram(TestCase):
|
||||
"""Tests covering querystring stripping."""
|
||||
catalog_program = factories.Program()
|
||||
|
||||
def test_munge_catalog_program(self):
|
||||
munged = utils.munge_catalog_program(self.catalog_program)
|
||||
expected = {
|
||||
'id': self.catalog_program['uuid'],
|
||||
'name': self.catalog_program['title'],
|
||||
'subtitle': self.catalog_program['subtitle'],
|
||||
'category': self.catalog_program['type'],
|
||||
'marketing_slug': self.catalog_program['marketing_slug'],
|
||||
'organizations': [
|
||||
{
|
||||
'display_name': organization['name'],
|
||||
'key': organization['key']
|
||||
} for organization in self.catalog_program['authoring_organizations']
|
||||
],
|
||||
'course_codes': [
|
||||
{
|
||||
'display_name': course['title'],
|
||||
'key': course['key'],
|
||||
'organization': {
|
||||
'display_name': course['owners'][0]['name'],
|
||||
'key': course['owners'][0]['key']
|
||||
},
|
||||
'run_modes': [
|
||||
{
|
||||
'course_key': run['key'],
|
||||
'run_key': CourseKey.from_string(run['key']).run,
|
||||
'mode_slug': 'verified'
|
||||
} for run in course['course_runs']
|
||||
],
|
||||
} for course in self.catalog_program['courses']
|
||||
],
|
||||
'banner_image_urls': {
|
||||
'w1440h480': self.catalog_program['banner_image']['large']['url'],
|
||||
'w726h242': self.catalog_program['banner_image']['medium']['url'],
|
||||
'w435h145': self.catalog_program['banner_image']['small']['url'],
|
||||
'w348h116': self.catalog_program['banner_image']['x-small']['url'],
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(munged, expected)
|
||||
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
|
||||
@mock.patch('config_models.models.cache.get', return_value=None)
|
||||
class TestGetCourseRun(mixins.CatalogIntegrationMixin, TestCase):
|
||||
"""Tests covering retrieval of course runs from the catalog service."""
|
||||
def setUp(self):
|
||||
|
||||
@@ -3,12 +3,114 @@ from urlparse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from edx_rest_api_client.client import EdxRestApiClient
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core.djangoapps.catalog.models import CatalogIntegration
|
||||
from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
|
||||
|
||||
def create_catalog_api_client(user, catalog_integration):
|
||||
"""Returns an API client which can be used to make catalog API requests."""
|
||||
scopes = ['email', 'profile']
|
||||
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
|
||||
jwt = JwtBuilder(user).build_token(scopes, expires_in)
|
||||
|
||||
return EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt)
|
||||
|
||||
|
||||
def get_programs(user, uuid=None, type=None): # pylint: disable=redefined-builtin
|
||||
"""Retrieve marketable programs from the catalog service.
|
||||
|
||||
Keyword Arguments:
|
||||
uuid (string): UUID identifying a specific program.
|
||||
type (string): Filter programs by type (e.g., "MicroMasters" will only return MicroMasters programs).
|
||||
|
||||
Returns:
|
||||
list of dict, representing programs.
|
||||
dict, if a specific program is requested.
|
||||
"""
|
||||
catalog_integration = CatalogIntegration.current()
|
||||
|
||||
if catalog_integration.enabled:
|
||||
api = create_catalog_api_client(user, catalog_integration)
|
||||
|
||||
cache_key = '{base}.programs{type}'.format(
|
||||
base=catalog_integration.CACHE_KEY,
|
||||
type='.' + type if type else ''
|
||||
)
|
||||
|
||||
querystring = {'marketable': 1}
|
||||
if type:
|
||||
querystring['type'] = type
|
||||
|
||||
return get_edx_api_data(
|
||||
catalog_integration,
|
||||
user,
|
||||
'programs',
|
||||
resource_id=uuid,
|
||||
cache_key=cache_key if catalog_integration.is_cache_enabled else None,
|
||||
api=api,
|
||||
querystring=querystring,
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
def munge_catalog_program(catalog_program):
|
||||
"""Make a program from the catalog service look like it came from the programs service.
|
||||
|
||||
Catalog-based MicroMasters need to be displayed in the LMS. However, the LMS
|
||||
currently retrieves all program data from the soon-to-be-retired programs service.
|
||||
Consuming program data exclusively from the catalog service would have taken more time
|
||||
than we had prior to the MicroMasters launch. This is a functional middle ground
|
||||
introduced by ECOM-5460. Cleaning up this debt is tracked by ECOM-4418.
|
||||
|
||||
Arguments:
|
||||
catalog_program (dict): The catalog service's representation of a program.
|
||||
|
||||
Return:
|
||||
dict, imitating the schema used by the programs service.
|
||||
"""
|
||||
return {
|
||||
'id': catalog_program['uuid'],
|
||||
'name': catalog_program['title'],
|
||||
'subtitle': catalog_program['subtitle'],
|
||||
'category': catalog_program['type'],
|
||||
'marketing_slug': catalog_program['marketing_slug'],
|
||||
'organizations': [
|
||||
{
|
||||
'display_name': organization['name'],
|
||||
'key': organization['key']
|
||||
} for organization in catalog_program['authoring_organizations']
|
||||
],
|
||||
'course_codes': [
|
||||
{
|
||||
'display_name': course['title'],
|
||||
'key': course['key'],
|
||||
'organization': {
|
||||
# The Programs schema only supports one organization here.
|
||||
'display_name': course['owners'][0]['name'],
|
||||
'key': course['owners'][0]['key']
|
||||
},
|
||||
'run_modes': [
|
||||
{
|
||||
'course_key': run['key'],
|
||||
'run_key': CourseKey.from_string(run['key']).run,
|
||||
'mode_slug': 'verified'
|
||||
} for run in course['course_runs']
|
||||
],
|
||||
} for course in catalog_program['courses']
|
||||
],
|
||||
'banner_image_urls': {
|
||||
'w1440h480': catalog_program['banner_image']['large']['url'],
|
||||
'w726h242': catalog_program['banner_image']['medium']['url'],
|
||||
'w435h145': catalog_program['banner_image']['small']['url'],
|
||||
'w348h116': catalog_program['banner_image']['x-small']['url'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_course_run(course_key, user):
|
||||
"""Get a course run's data from the course catalog service.
|
||||
|
||||
@@ -22,10 +124,7 @@ def get_course_run(course_key, user):
|
||||
catalog_integration = CatalogIntegration.current()
|
||||
|
||||
if catalog_integration.enabled:
|
||||
scopes = ['email', 'profile']
|
||||
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
|
||||
jwt = JwtBuilder(user).build_token(scopes, expires_in)
|
||||
api = EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt)
|
||||
api = create_catalog_api_client(user, catalog_integration)
|
||||
|
||||
data = get_edx_api_data(
|
||||
catalog_integration,
|
||||
|
||||
@@ -14,7 +14,11 @@ import pytz
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.certificates import api as certificate_api
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from openedx.core.djangoapps.catalog.utils import get_run_marketing_url
|
||||
from openedx.core.djangoapps.catalog.utils import (
|
||||
get_programs as get_catalog_programs,
|
||||
munge_catalog_program,
|
||||
get_run_marketing_url,
|
||||
)
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
@@ -31,6 +35,7 @@ DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=pytz.UTC)
|
||||
|
||||
def get_programs(user, program_id=None):
|
||||
"""Given a user, get programs from the Programs service.
|
||||
|
||||
Returned value is cached depending on user permissions. Staff users making requests
|
||||
against Programs will receive unpublished programs, while regular users will only receive
|
||||
published programs.
|
||||
@@ -43,6 +48,7 @@ def get_programs(user, program_id=None):
|
||||
|
||||
Returns:
|
||||
list of dict, representing programs returned by the Programs service.
|
||||
dict, if a specific program is requested.
|
||||
"""
|
||||
programs_config = ProgramsApiConfig.current()
|
||||
|
||||
@@ -50,7 +56,15 @@ def get_programs(user, program_id=None):
|
||||
# to see them displayed immediately.
|
||||
cache_key = programs_config.CACHE_KEY if programs_config.is_cache_enabled and not user.is_staff else None
|
||||
|
||||
return get_edx_api_data(programs_config, user, 'programs', resource_id=program_id, cache_key=cache_key)
|
||||
programs = get_edx_api_data(programs_config, user, 'programs', resource_id=program_id, cache_key=cache_key)
|
||||
|
||||
# Mix in munged MicroMasters data from the catalog.
|
||||
if not program_id:
|
||||
programs += [
|
||||
munge_catalog_program(micromaster) for micromaster in get_catalog_programs(user, type='MicroMasters')
|
||||
]
|
||||
|
||||
return programs
|
||||
|
||||
|
||||
def get_programs_for_credentials(user, programs_credentials):
|
||||
|
||||
Reference in New Issue
Block a user