EDUCATOR-4543 | Add utility functions to the catalog module that picks out all of the course_runs associated with a program, even those that live inside the curriculum of a sub-program. Use these functions to populate the course -> program index of the catalog/programs cache. Also some refactoring of a program_enrollments overview endpoint.

This commit is contained in:
Alex Dusenbery
2019-08-01 14:28:56 -04:00
committed by Alex Dusenbery
parent 98f740f71a
commit 70e2aaa95b
7 changed files with 297 additions and 102 deletions

View File

@@ -17,7 +17,7 @@ from openedx.core.djangoapps.catalog.cache import (
SITE_PROGRAM_UUIDS_CACHE_KEY_TPL,
)
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.catalog.utils import create_catalog_api_client
from openedx.core.djangoapps.catalog.utils import create_catalog_api_client, course_run_keys_for_program
logger = logging.getLogger(__name__)
User = get_user_model() # pylint: disable=invalid-name
@@ -218,8 +218,7 @@ class Command(BaseCommand):
failure = False
for program in programs.values():
for course in program['courses']:
for course_run in course['course_runs']:
course_run_cache_key = COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=course_run['key'])
course_runs[course_run_cache_key].append(program['uuid'])
for course_run_key in course_run_keys_for_program(program):
course_run_cache_key = COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=course_run_key)
course_runs[course_run_cache_key].append(program['uuid'])
return course_runs, failure

View File

@@ -9,6 +9,7 @@ from django.core.cache import cache
from django.core.management import call_command
from openedx.core.djangoapps.catalog.cache import (
COURSE_PROGRAMS_CACHE_KEY_TPL,
PATHWAY_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
SITE_PATHWAY_IDS_CACHE_KEY_TPL,
@@ -24,6 +25,9 @@ from student.tests.factories import UserFactory
@skip_unless_lms
@httpretty.activate
class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMixin):
"""
Defines tests for the ``cache_programs`` management command.
"""
ENABLED_CACHES = ['default']
def setUp(self):
@@ -46,6 +50,10 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix
self.programs = ProgramFactory.create_batch(3)
self.pathways = PathwayFactory.create_batch(3)
self.child_program = ProgramFactory.create()
self.programs[0]['curricula'][0]['programs'].append(self.child_program)
self.programs.append(self.child_program)
for pathway in self.pathways:
self.programs += pathway['programs']
@@ -57,7 +65,10 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix
self.pathways[1]['programs'].append(self.programs[0])
def mock_list(self):
""" Mock the data returned by the program listing API endpoint. """
# pylint: disable=unused-argument
def list_callback(request, uri, headers):
""" The mock listing callback. """
expected = {
'exclude_utm': ['1'],
'status': ['active', 'retired'],
@@ -75,7 +86,10 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix
)
def mock_detail(self, uuid, program):
""" Mock the data returned by the program detail API endpoint. """
# pylint: disable=unused-argument
def detail_callback(request, uri, headers):
""" The mock detail callback. """
expected = {
'exclude_utm': ['1'],
}
@@ -91,13 +105,9 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix
)
def mock_pathways(self, pathways, page_number=1, final=True):
"""
Mock the data for discovery's credit pathways endpoint
"""
""" Mock the data for discovery's credit pathways endpoint. """
def pathways_callback(request, uri, headers): # pylint: disable=unused-argument
"""
Mocks response
"""
""" Mocks the pathways response. """
expected = {
'exclude_utm': ['1'],
@@ -171,6 +181,15 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix
del program['pathway_ids']
self.assertEqual(program, programs[key])
# the courses in the child program's first curriculum (the active one)
# should point to both the child program and the first program
# in the cache.
for course in self.child_program['curricula'][0]['courses']:
for course_run in course['course_runs']:
course_run_cache_key = COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=course_run['key'])
self.assertIn(self.programs[0]['uuid'], cache.get(course_run_cache_key))
self.assertIn(self.child_program['uuid'], cache.get(course_run_cache_key))
def test_handle_pathways(self):
"""
Verify that the command requests and caches credit pathways

View File

@@ -197,7 +197,7 @@ def generate_curricula():
class ProgramFactory(DictFactoryBase):
authoring_organizations = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1))
applicable_seat_types = []
applicable_seat_types = factory.LazyFunction(lambda: [])
banner_image = factory.LazyFunction(generate_sized_stdimage)
card_image_url = factory.Faker('image_url')
corporate_endorsements = factory.LazyFunction(partial(generate_instances, CorporateEndorsementFactory))
@@ -232,7 +232,7 @@ class CurriculumFactory(DictFactoryBase):
marketing_text_brief = factory.Faker('word')
is_active = True
courses = factory.LazyFunction(partial(generate_instances, CourseFactory))
programs = []
programs = factory.LazyFunction(lambda: [])
class ProgramTypeFactory(DictFactoryBase):

View File

@@ -35,6 +35,8 @@ from openedx.core.djangoapps.catalog.tests.factories import (
)
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.catalog.utils import (
child_programs,
course_run_keys_for_program,
get_course_run_details,
get_course_runs,
get_course_runs_for_course,
@@ -663,3 +665,123 @@ class TestGetCourseRunDetails(CatalogIntegrationMixin, TestCase):
data = get_course_run_details(course_run['key'], ['content_language', 'weeks_to_complete', 'max_effort'])
self.assertTrue(mock_get_edx_api_data.called)
self.assertEqual(data, course_run_details)
class TestProgramCourseRunCrawling(TestCase):
@classmethod
def setUpClass(cls):
super(TestProgramCourseRunCrawling, cls).setUpClass()
cls.grandchild_1 = {
'title': 'grandchild 1',
'curricula': [{'is_active': True, 'courses': [], 'programs': []}],
}
cls.grandchild_2 = {
'title': 'grandchild 2',
'curricula': [
{
'is_active': True,
'courses': [{
'course_runs': [
{'key': 'course-run-4'},
],
}],
'programs': [],
},
],
}
cls.grandchild_3 = {
'title': 'grandchild 3',
'curricula': [{'is_active': False}],
}
cls.child_1 = {
'title': 'child 1',
'curricula': [{'is_active': True, 'courses': [], 'programs': [cls.grandchild_1]}],
}
cls.child_2 = {
'title': 'child 2',
'curricula': [
{
'is_active': True,
'courses': [{
'course_runs': [
{'key': 'course-run-3'},
],
}],
'programs': [cls.grandchild_2, cls.grandchild_3],
},
],
}
cls.complex_program = {
'title': 'complex program',
'curricula': [
{
'is_active': True,
'courses': [{
'course_runs': [
{'key': 'course-run-2'},
],
}],
'programs': [cls.child_1, cls.child_2],
},
],
}
cls.simple_program = {
'title': 'simple program',
'curricula': [
{
'is_active': True,
'courses': [{
'course_runs': [
{'key': 'course-run-1'},
],
}],
'programs': [cls.grandchild_1]
},
],
}
cls.empty_program = {
'title': 'notice that I have a curriculum, but no programs inside it',
'curricula': [
{
'is_active': True,
'courses': [],
'programs': [],
},
],
}
def test_child_programs_no_curriculum(self):
program = {
'title': 'notice that I do not have a curriculum',
}
self.assertEqual([], child_programs(program))
def test_child_programs_no_children(self):
self.assertEqual([], child_programs(self.empty_program))
def test_child_programs_one_child(self):
self.assertEqual([self.grandchild_1], child_programs(self.simple_program))
def test_child_programs_many_children(self):
expected_children = [
self.child_1,
self.grandchild_1,
self.child_2,
self.grandchild_2,
self.grandchild_3,
]
self.assertEqual(expected_children, child_programs(self.complex_program))
def test_course_run_keys_for_program_no_courses(self):
self.assertEqual(set(), course_run_keys_for_program(self.empty_program))
def test_course_run_keys_for_program_one_course(self):
self.assertEqual({'course-run-1'}, course_run_keys_for_program(self.simple_program))
def test_course_run_keys_for_program_many_courses(self):
expected_course_runs = {
'course-run-2',
'course-run-3',
'course-run-4',
}
self.assertEqual(expected_course_runs, course_run_keys_for_program(self.complex_program))

View File

@@ -543,3 +543,56 @@ def get_course_run_details(course_run_key, fields):
course_run_details = get_edx_api_data(catalog_integration, 'course_runs', api, resource_id=course_run_key,
cache_key=cache_key, many=False, traverse_pagination=False, fields=fields)
return course_run_details
def course_run_keys_for_program(parent_program):
"""
All of the course run keys associated with this ``parent_program``, either
via its ``curriculum`` field (looking at both the curriculum's courses
and child programs), or through the many-to-many ``courses`` field on the program.
"""
keys = set()
for program in [parent_program] + child_programs(parent_program):
curriculum = _primary_active_curriculum(program)
if curriculum:
keys.update(_course_runs_from_container(curriculum))
keys.update(_course_runs_from_container(program))
return keys
def child_programs(program):
"""
Given a program, recursively find all child programs related
to this program through its curricula.
"""
curriculum = _primary_active_curriculum(program)
if not curriculum:
return []
result = []
for child in curriculum.get('programs', []):
result.append(child)
result.extend(child_programs(child))
return result
def _primary_active_curriculum(program):
"""
Returns the first active curriculum in the given program, or None.
"""
try:
return next(c for c in program.get('curricula', []) if c.get('is_active'))
except StopIteration:
return
def _course_runs_from_container(container):
"""
Pluck nested course runs out of a ``container`` dictionary,
which is either the ``curriculum`` field of a program, or
a program itself (since either may contain a ``courses`` list).
"""
return [
course_run.get('key')
for course in container.get('courses', [])
for course_run in course.get('course_runs', [])
]