Merge pull request #14339 from edx/hasnain-naveed/program-backend/WL-912

WL-766 Program marketing page data layer
This commit is contained in:
Douglas Hall
2017-02-14 04:53:53 -05:00
committed by GitHub
9 changed files with 197 additions and 107 deletions

View File

@@ -6,7 +6,6 @@ import copy
from django.contrib.auth import get_user_model
from django.test import TestCase
import mock
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory, ProgramTypeFactory
@@ -14,7 +13,7 @@ from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.catalog.utils import (
get_programs,
get_program_types,
get_programs_with_type_logo,
get_programs_with_type,
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory
@@ -32,12 +31,12 @@ class TestGetPrograms(CatalogIntegrationMixin, TestCase):
super(TestGetPrograms, self).setUp()
self.uuid = str(uuid.uuid4())
self.type = 'FooBar'
self.types = ['Foo', 'Bar', 'FooBar']
self.catalog_integration = self.create_catalog_integration(cache_ttl=1)
UserFactory(username=self.catalog_integration.service_username)
def assert_contract(self, call_args, program_uuid=None, type=None): # pylint: disable=redefined-builtin
def assert_contract(self, call_args, program_uuid=None, types=None): # pylint: disable=redefined-builtin
"""Verify that API data retrieval utility is used correctly."""
args, kwargs = call_args
@@ -46,9 +45,10 @@ class TestGetPrograms(CatalogIntegrationMixin, TestCase):
self.assertEqual(kwargs['resource_id'], program_uuid)
cache_key = '{base}.programs{type}'.format(
types_param = ','.join(types) if types and isinstance(types, list) else None
cache_key = '{base}.programs{types}'.format(
base=self.catalog_integration.CACHE_KEY,
type='.' + type if type else ''
types='.' + types_param if types_param else ''
)
self.assertEqual(
kwargs['cache_key'],
@@ -61,8 +61,10 @@ class TestGetPrograms(CatalogIntegrationMixin, TestCase):
'marketable': 1,
'exclude_utm': 1,
}
if type:
querystring['type'] = type
if program_uuid:
querystring['use_full_course_serializer'] = 1
if types:
querystring['types'] = types_param
self.assertEqual(kwargs['querystring'], querystring)
return args, kwargs
@@ -85,13 +87,13 @@ class TestGetPrograms(CatalogIntegrationMixin, TestCase):
self.assert_contract(mock_get_edx_api_data.call_args, program_uuid=self.uuid)
self.assertEqual(data, program)
def test_get_programs_by_type(self, mock_get_edx_api_data):
def test_get_programs_by_types(self, mock_get_edx_api_data):
programs = ProgramFactory.create_batch(2)
mock_get_edx_api_data.return_value = programs
data = get_programs(type=self.type)
data = get_programs(types=self.types)
self.assert_contract(mock_get_edx_api_data.call_args, type=self.type)
self.assert_contract(mock_get_edx_api_data.call_args, types=self.types)
self.assertEqual(data, programs)
def test_programs_unavailable(self, mock_get_edx_api_data):
@@ -129,13 +131,37 @@ class TestGetPrograms(CatalogIntegrationMixin, TestCase):
data = get_programs()
self.assertEqual(data, [])
@mock.patch(UTILS_MODULE + '.get_programs')
@mock.patch(UTILS_MODULE + '.get_program_types')
def test_get_programs_with_type(self, mock_get_program_types, mock_get_programs, _mock_get_edx_api_data):
"""Verify get_programs_with_type returns the expected list of programs."""
programs_with_program_type = []
programs = ProgramFactory.create_batch(2)
program_types = []
for program in programs:
program_type = ProgramTypeFactory(name=program['type'])
program_types.append(program_type)
program_with_type = copy.deepcopy(program)
program_with_type['type'] = program_type
programs_with_program_type.append(program_with_type)
mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types
actual = get_programs_with_type()
self.assertEqual(actual, programs_with_program_type)
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
class TestGetProgramTypes(CatalogIntegrationMixin, TestCase):
"""Tests covering retrieval of program types from the catalog service."""
def test_get_program_types(self, mock_get_edx_api_data):
program_types = [ProgramTypeFactory() for __ in range(3)]
"""Verify get_program_types returns the expected list of program types."""
program_types = ProgramTypeFactory.create_batch(3)
mock_get_edx_api_data.return_value = program_types
# Catalog integration is disabled.
@@ -147,28 +173,6 @@ class TestGetProgramTypes(CatalogIntegrationMixin, TestCase):
data = get_program_types()
self.assertEqual(data, program_types)
def test_get_programs_with_type_logo(self, _mock_get_edx_api_data):
programs = []
program_types = []
programs_with_type_logo = []
for index in range(3):
# Creating the Programs and their corresponding program types.
type_name = 'type_name_{postfix}'.format(postfix=index)
program = ProgramFactory(type=type_name)
program_type = ProgramTypeFactory(name=type_name)
programs.append(program)
program_types.append(program_type)
program_with_type_logo = copy.deepcopy(program)
program_with_type_logo['logo_image'] = program_type['logo_image']
programs_with_type_logo.append(program_with_type_logo)
with mock.patch('openedx.core.djangoapps.catalog.utils.get_programs') as patched_get_programs:
with mock.patch('openedx.core.djangoapps.catalog.utils.get_program_types') as patched_get_program_types:
patched_get_programs.return_value = programs
patched_get_program_types.return_value = program_types
actual = get_programs_with_type_logo()
self.assertEqual(actual, programs_with_type_logo)
program = program_types[0]
data = get_program_types(name=program['name'])
self.assertEqual(data, program)

View File

@@ -1,8 +1,9 @@
"""Helper functions for working with the catalog service."""
import copy
from django.conf import settings
from django.contrib.auth import get_user_model
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
@@ -21,12 +22,14 @@ def create_catalog_api_client(user, catalog_integration):
return EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt)
def get_programs(uuid=None, type=None): # pylint: disable=redefined-builtin
def get_programs(uuid=None, types=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).
types (list of string): List of program type names used to filter programs by type
(e.g., ["MicroMasters"] will only return MicroMasters programs,
["MicroMasters", "XSeries"] will return MicroMasters and XSeries programs).
Returns:
list of dict, representing programs.
@@ -40,18 +43,21 @@ def get_programs(uuid=None, type=None): # pylint: disable=redefined-builtin
return []
api = create_catalog_api_client(user, catalog_integration)
types_param = ','.join(types) if types else None
cache_key = '{base}.programs{type}'.format(
cache_key = '{base}.programs{types}'.format(
base=catalog_integration.CACHE_KEY,
type='.' + type if type else ''
types='.' + types_param if types_param else ''
)
querystring = {
'marketable': 1,
'exclude_utm': 1,
}
if type:
querystring['type'] = type
if uuid:
querystring['use_full_course_serializer'] = 1
if types_param:
querystring['types'] = types_param
return get_edx_api_data(
catalog_integration,
@@ -66,11 +72,15 @@ def get_programs(uuid=None, type=None): # pylint: disable=redefined-builtin
return []
def get_program_types():
"""Retrieve all program types from the catalog service.
def get_program_types(name=None):
"""Retrieve program types from the catalog service.
Keyword Arguments:
name (string): Name identifying a specific program.
Returns:
list of dict, representing program types.
dict, if a specific program type is requested.
"""
catalog_integration = CatalogIntegration.current()
if catalog_integration.enabled:
@@ -82,27 +92,46 @@ def get_program_types():
api = create_catalog_api_client(user, catalog_integration)
cache_key = '{base}.program_types'.format(base=catalog_integration.CACHE_KEY)
return get_edx_api_data(
data = get_edx_api_data(
catalog_integration,
user,
'program_types',
cache_key=cache_key if catalog_integration.is_cache_enabled else None,
api=api
)
# Filter by name if a name was provided
if name:
data = next(program_type for program_type in data if program_type['name'] == name)
return data
else:
return []
def get_programs_with_type_logo():
def get_programs_with_type(types=None):
"""
Join program type logos with programs of corresponding type.
Return the list of programs. You can filter the types of programs returned using the optional
types parameter. If no filter is provided, all programs of all types will be returned.
The program dict is updated with the fully serialized program type.
Keyword Arguments:
types (list): List of program type slugs to filter by.
Return:
list of dict, representing the active programs.
"""
programs_list = get_programs()
program_types = get_program_types()
programs_with_type = []
programs = get_programs(types=types)
type_logo_map = {program_type['name']: program_type['logo_image'] for program_type in program_types}
if programs:
program_types = {program_type['name']: program_type for program_type in get_program_types()}
for program in programs:
# deepcopy the program dict here so we are not adding
# the type to the cached object
program_with_type = copy.deepcopy(program)
program_with_type['type'] = program_types[program['type']]
programs_with_type.append(program_with_type)
for program in programs_list:
program['logo_image'] = type_logo_map[program['type']]
return programs_list
return programs_with_type

View File

@@ -1,17 +1,14 @@
"""Tests covering Programs utilities."""
# pylint: disable=no-member
import datetime
import json
import uuid
import ddt
from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
import mock
from nose.plugins.attrib import attr
from opaque_keys.edx.keys import CourseKey
from pytz import utc
from lms.djangoapps.certificates.api import MODES
@@ -21,15 +18,12 @@ from openedx.core.djangoapps.catalog.tests.factories import (
ProgramFactory,
CourseFactory,
CourseRunFactory,
OrganizationFactory,
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin
from openedx.core.djangoapps.programs.tests.factories import ProgressFactory
from openedx.core.djangoapps.programs.utils import (
DEFAULT_ENROLLMENT_START_DATE, ProgramProgressMeter, ProgramDataExtender
)
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from util.date_utils import strftime_localized
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -388,6 +382,18 @@ class TestProgramDataExtender(ModuleStoreTestCase):
maxDiff = None
sku = 'abc123'
checkout_path = '/basket'
instructors = {
'instructors': [
{
'name': 'test-instructor1',
'organization': 'TextX',
},
{
'name': 'test-instructor2',
'organization': 'TextX',
}
]
}
def setUp(self):
super(TestProgramDataExtender, self).setUp()
@@ -395,6 +401,7 @@ class TestProgramDataExtender(ModuleStoreTestCase):
self.course = ModuleStoreCourseFactory()
self.course.start = datetime.datetime.now(utc) - datetime.timedelta(days=1)
self.course.end = datetime.datetime.now(utc) + datetime.timedelta(days=1)
self.course.instructor_info = self.instructors
self.course = self.update_course(self.course, self.user.id)
self.course_run = CourseRunFactory(key=unicode(self.course.id))
@@ -561,3 +568,9 @@ class TestProgramDataExtender(ModuleStoreTestCase):
) if is_uuid_available else None
self._assert_supplemented(data, certificate_url=expected_url)
def test_instructors_retrieval(self):
data = ProgramDataExtender(self.program, self.user).extend(include_instructors=True)
self.program.update(self.instructors['instructors'])
self.assertEqual(data, self.program)

View File

@@ -5,6 +5,7 @@ import datetime
from urlparse import urljoin
from django.conf import settings
from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.utils.functional import cached_property
from opaque_keys.edx.keys import CourseKey
@@ -15,9 +16,9 @@ from lms.djangoapps.certificates import api as certificate_api
from lms.djangoapps.commerce.utils import EcommerceService
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.models import CourseEnrollment
from util.date_utils import strftime_localized
from xmodule.modulestore.django import modulestore
# The datetime module's strftime() methods require a year >= 1900.
@@ -247,9 +248,12 @@ class ProgramDataExtender(object):
self.course_overview = None
self.enrollment_start = None
def extend(self):
def extend(self, include_instructors=False):
"""Execute extension handlers, returning the extended data."""
self._execute('_extend')
if include_instructors:
self._execute('_extend')
else:
self._execute('_extend_course_runs')
return self.data
def _execute(self, prefix, *args):
@@ -261,6 +265,9 @@ class ProgramDataExtender(object):
"""Returns a generator yielding method names beginning with the given prefix."""
return (name for name in cls.__dict__ if name.startswith(prefix))
def _extend_with_instructors(self):
self._execute('_attach_instructors')
def _extend_course_runs(self):
"""Execute course run data handlers."""
for course in self.data['courses']:
@@ -326,3 +333,32 @@ class ProgramDataExtender(object):
run_mode['upgrade_url'] = None
else:
run_mode['upgrade_url'] = None
def _attach_instructors(self):
"""
Extend the program data with instructor data. The instructor data added here is persisted
on each course in modulestore and can be edited in Studio. Once the course metadata publisher tool
supports the authoring of course instructor data, we will be able to migrate course
instructor data into the catalog, retrieve it via the catalog API, and remove this code.
"""
cache_key = 'program.instructors.{uuid}'.format(
uuid=self.data['uuid']
)
program_instructors = cache.get(cache_key)
if not program_instructors:
instructors_by_name = {}
module_store = modulestore()
for course in self.data['courses']:
for course_run in course['course_runs']:
course_run_key = CourseKey.from_string(course_run['key'])
course_descriptor = module_store.get_course(course_run_key)
if course_descriptor:
course_instructors = getattr(course_descriptor, 'instructor_info', {})
# Deduplicate program instructors using instructor name
instructors_by_name.update({instructor.get('name'): instructor for instructor
in course_instructors.get('instructors', [])})
program_instructors = instructors_by_name.values()
cache.set(cache_key, program_instructors, 3600)
self.data['instructors'] = program_instructors