Merge pull request #23255 from edx/aj/MICROBA-150_2

Create MB External ID on creation of a CourseEntitlement in MB Program
This commit is contained in:
Albert (AJ) St. Aubin
2020-03-02 14:37:53 -05:00
committed by GitHub
7 changed files with 187 additions and 18 deletions

View File

@@ -10,9 +10,12 @@ PATHWAY_CACHE_KEY_TPL = 'pathway-{id}'
# Cache key used to locate an item containing a list of all credit pathway ids for a site.
SITE_PATHWAY_IDS_CACHE_KEY_TPL = 'pathway-ids-{domain}'
# Template used to create cache keys for individual courses to program uuids.
# Template used to create cache keys for individual course runs to program uuids.
COURSE_PROGRAMS_CACHE_KEY_TPL = 'course-programs-{course_run_id}'
# Template used to create cache keys for individual Courses to program uuids.
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL = 'catalog-course-programs-{course_uuid}'
# Site-aware cache key template used to locate an item containing
# a list of all program UUIDs with a certain program type (the Site is required
# because program_type values are likely to be shared between different sites

View File

@@ -13,6 +13,7 @@ from six import text_type
from openedx.core.djangoapps.catalog.cache import (
COURSE_PROGRAMS_CACHE_KEY_TPL,
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
PATHWAY_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL,
@@ -23,6 +24,7 @@ from openedx.core.djangoapps.catalog.cache import (
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.catalog.utils import (
course_run_keys_for_program,
course_uuids_for_program,
create_catalog_api_client,
normalize_program_type
)
@@ -60,6 +62,7 @@ class Command(BaseCommand):
programs = {}
pathways = {}
courses = {}
catalog_courses = {}
programs_by_type = {}
organizations = {}
for site in Site.objects.all():
@@ -88,6 +91,7 @@ class Command(BaseCommand):
programs.update(new_programs)
pathways.update(new_pathways)
courses.update(self.get_courses(new_programs))
catalog_courses.update(self.get_catalog_courses(new_programs))
programs_by_type.update(self.get_programs_by_type(site, new_programs))
organizations.update(self.get_programs_by_organization(new_programs))
@@ -113,6 +117,9 @@ class Command(BaseCommand):
logger.info(u'Caching programs uuids for {} courses.'.format(len(courses)))
cache.set_many(courses, None)
logger.info(u'Caching programs uuids for {} catalog courses.'.format(len(catalog_courses)))
cache.set_many(catalog_courses, None)
logger.info(text_type('Caching program UUIDs by {} program types.'.format(len(programs_by_type))))
cache.set_many(programs_by_type, None)
@@ -218,7 +225,7 @@ class Command(BaseCommand):
def get_courses(self, programs):
"""
Get all courses for the programs.
Get all course runs for programs.
TODO: when course discovery can handle it, use that instead. That will allow us to put all course runs
in the cache not just the course runs in a program. Therefore, a cache miss would be different from a
@@ -232,6 +239,18 @@ class Command(BaseCommand):
course_runs[course_run_cache_key].append(program['uuid'])
return course_runs
def get_catalog_courses(self, programs):
"""
Get all catalog courses for the programs.
"""
courses = defaultdict(list)
for program in programs.values():
for course_uuid in course_uuids_for_program(program):
course_cache_key = CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=course_uuid)
courses[course_cache_key].append(program['uuid'])
return courses
def get_programs_by_type(self, site, programs):
"""
Returns a dictionary mapping site-aware cache keys corresponding to program types

View File

@@ -19,6 +19,7 @@ from course_modes.tests.factories import CourseModeFactory
from entitlements.tests.factories import CourseEntitlementFactory
from openedx.core.constants import COURSE_UNPUBLISHED
from openedx.core.djangoapps.catalog.cache import (
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
COURSE_PROGRAMS_CACHE_KEY_TPL,
PATHWAY_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
@@ -238,6 +239,28 @@ class TestGetPrograms(CacheIsolationTestCase):
assert second_program in results
assert not mock_warning.called
def test_get_from_catalog_course(self, mock_warning, _mock_info):
expected_program = ProgramFactory()
expected_catalog_course = expected_program['courses'][0]
self.assertEqual(get_programs(catalog_course_uuid=expected_catalog_course['uuid']), [])
cache.set(
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=expected_catalog_course['uuid']),
[expected_program['uuid']],
None
)
cache.set(
PROGRAM_CACHE_KEY_TPL.format(uuid=expected_program['uuid']),
expected_program,
None
)
actual_program = get_programs(catalog_course_uuid=expected_catalog_course['uuid'])
self.assertEqual(actual_program, [expected_program])
self.assertFalse(mock_warning.called)
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.logger.info')

View File

@@ -18,6 +18,7 @@ from entitlements.utils import is_course_run_entitlement_fulfillable
from openedx.core.constants import COURSE_PUBLISHED
from openedx.core.djangoapps.catalog.cache import (
COURSE_PROGRAMS_CACHE_KEY_TPL,
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL,
PATHWAY_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
@@ -87,7 +88,7 @@ def check_catalog_integration_and_get_user(error_message_field):
# pylint: disable=redefined-outer-name
def get_programs(site=None, uuid=None, uuids=None, course=None, organization=None):
def get_programs(site=None, uuid=None, uuids=None, course=None, catalog_course_uuid=None, organization=None):
"""Read programs from the cache.
The cache is populated by a management command, cache_programs.
@@ -96,14 +97,15 @@ def get_programs(site=None, uuid=None, uuids=None, course=None, organization=Non
site (Site): django.contrib.sites.models object to fetch programs of.
uuid (string): UUID identifying a specific program to read from the cache.
uuids (list of string): UUIDs identifying a specific programs to read from the cache.
course (string): course id identifying a specific course run to read from the cache.
course (string): course run id identifying a specific course run to read from the cache.
catalog_course_uuid (string): Catalog Course UUID
organization (string): short name for specific organization to read from the cache.
Returns:
list of dict, representing programs.
dict, if a specific program is requested.
"""
if len([arg for arg in (site, uuid, uuids, course, organization) if arg is not None]) != 1:
if len([arg for arg in (site, uuid, uuids, course, catalog_course_uuid, organization) if arg is not None]) != 1:
raise TypeError('get_programs takes exactly one argument')
if uuid:
@@ -118,6 +120,12 @@ def get_programs(site=None, uuid=None, uuids=None, course=None, organization=Non
# Currently, the cache does not differentiate between a cache miss and a course
# without programs. After this is changed, log any cache misses here.
return []
elif catalog_course_uuid:
uuids = cache.get(CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=catalog_course_uuid))
if not uuids:
# Currently, the cache does not differentiate between a cache miss and a course
# without programs. After this is changed, log any cache misses here.
return []
elif site:
site_config = getattr(site, 'configuration', None)
catalog_url = site_config.get_value('COURSE_CATALOG_API_URL') if site_config else None
@@ -616,6 +624,21 @@ def course_run_keys_for_program(parent_program):
return keys
def course_uuids_for_program(parent_program):
"""
All of the course uuids 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.
"""
uuids = set()
for program in [parent_program] + child_programs(parent_program):
curriculum = _primary_active_curriculum(program)
if curriculum:
uuids.update(_courses_from_container(curriculum))
uuids.update(_courses_from_container(program))
return uuids
def child_programs(program):
"""
Given a program, recursively find all child programs related
@@ -654,6 +677,18 @@ def _course_runs_from_container(container):
]
def _courses_from_container(container):
"""
Pluck nested courses 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.get('uuid')
for course in container.get('courses', [])
]
def normalize_program_type(program_type):
""" Function that normalizes a program type string for use in a cache key. """
return str(program_type).lower()

View File

@@ -61,10 +61,6 @@ class ExternalId(TimeStampedModel):
user=user,
external_id_type__name=type_name
).exists():
LOGGER.info('No external id for user id {user} with type of {type}'.format(
user=user.id,
type=type_name
))
return False
return True

View File

@@ -13,6 +13,16 @@ from .models import ExternalId, ExternalIdType
LOGGER = getLogger(__name__)
def _user_needs_external_id(instance, created):
return (
created and
instance.user and
not ExternalId.user_has_external_id(
user=instance.user,
type_name=ExternalIdType.MICROBACHELORS_COACHING)
)
@receiver(post_save, sender='student.CourseEnrollment')
def create_external_id_for_microbachelors_program(
sender, instance, created, **kwargs # pylint: disable=unused-argument
@@ -21,16 +31,30 @@ def create_external_id_for_microbachelors_program(
Watches for post_save signal for creates on the CourseEnrollment table.
Generate an External ID if the Enrollment is in a MicroBachelors Program
"""
if (
created and
instance.user and
not ExternalId.user_has_external_id(
user=instance.user,
type_name=ExternalIdType.MICROBACHELORS_COACHING)
):
if _user_needs_external_id(instance, created):
mb_programs = [
program for program in get_programs(course=instance.course_id)
if program.get('type_attrs', None) and program['type_attrs']['coaching_supported']
if program.get('type_attrs', {}).get('coaching_supported')
]
if mb_programs:
ExternalId.add_new_user_id(
user=instance.user,
type_name=ExternalIdType.MICROBACHELORS_COACHING
)
@receiver(post_save, sender='entitlements.CourseEntitlement')
def create_external_id_for_microbachelors_program_entitlement(
sender, instance, created, **kwargs # pylint: disable=unused-argument
):
"""
Watches for post_save signal for creates on the CourseEntitlement table.
Generate an External ID if the Entitlement is in a MicroBachelors Program
"""
if _user_needs_external_id(instance, created):
mb_programs = [
program for program in get_programs(catalog_course_uuid=instance.course_uuid)
if program.get('type_attrs', {}).get('coaching_supported')
]
if mb_programs:
ExternalId.add_new_user_id(

View File

@@ -7,12 +7,17 @@ from django.conf import settings
from django.core.cache import cache
from edx_django_utils.cache import RequestCache
from entitlements.models import CourseEntitlement
from openedx.core.djangoapps.catalog.tests.factories import (
CourseFactory,
ProgramFactory,
)
from student.tests.factories import TEST_PASSWORD, UserFactory
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, COURSE_PROGRAMS_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.cache import (
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
COURSE_PROGRAMS_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
)
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from openedx.core.djangolib.testing.utils import skip_unless_lms
@@ -64,6 +69,12 @@ class MicrobachelorsExternalIDTest(ModuleStoreTestCase, CacheIsolationTestCase):
program['type_attrs']['coaching_supported'] = True
for course in program['courses']:
cache.set(
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=course['uuid']),
[program['uuid']],
None
)
course_run = course['course_runs'][0]['key']
cache.set(
COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=course_run),
@@ -127,3 +138,61 @@ class MicrobachelorsExternalIDTest(ModuleStoreTestCase, CacheIsolationTestCase):
assert len(external_ids) == 1
assert external_ids[0].external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
assert original_external_user_uuid == external_ids[0].external_user_id
def test_entitlement_mb_create_external_id(self):
catalog_course = self.program['courses'][0]
assert ExternalId.objects.filter(
user=self.user
).count() == 0
entitlement = CourseEntitlement.objects.create(
course_uuid=catalog_course['uuid'],
mode=CourseMode.VERIFIED,
user=self.user,
order_number='TEST-12345'
)
entitlement.save()
external_id = ExternalId.objects.get(
user=self.user
)
assert external_id is not None
assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
def test_second_entitlement_mb_no_new_external_id(self):
catalog_course1 = self.program['courses'][0]
catalog_course2 = self.program['courses'][1]
# Enroll user
entitlement = CourseEntitlement.objects.create(
course_uuid=catalog_course1['uuid'],
mode=CourseMode.VERIFIED,
user=self.user,
order_number='TEST-12345'
)
entitlement.save()
external_id = ExternalId.objects.get(
user=self.user
)
assert external_id is not None
assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
original_external_user_uuid = external_id.external_user_id
CourseEntitlement.objects.create(
course_uuid=catalog_course2['uuid'],
mode=CourseMode.VERIFIED,
user=self.user,
order_number='TEST-12345'
)
entitlements = CourseEntitlement.objects.filter(user=self.user)
assert len(entitlements) == 2
external_ids = ExternalId.objects.filter(
user=self.user
)
assert len(external_ids) == 1
assert external_ids[0].external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
assert original_external_user_uuid == external_ids[0].external_user_id