diff --git a/enterprise_catalog/apps/api/tasks.py b/enterprise_catalog/apps/api/tasks.py index 61c05f029..b6e7f5717 100644 --- a/enterprise_catalog/apps/api/tasks.py +++ b/enterprise_catalog/apps/api/tasks.py @@ -11,6 +11,7 @@ from celery import shared_task, states from celery.exceptions import Ignore from celery_utils.logged_task import LoggedTask +from django.conf import settings from django.db import IntegrityError from django.db.models import Prefetch, Q from django.db.utils import OperationalError @@ -43,6 +44,7 @@ VIDEO, ) from enterprise_catalog.apps.catalog.content_metadata_utils import ( + remove_restricted_course_runs, transform_course_metadata_to_visible, ) from enterprise_catalog.apps.catalog.models import ( @@ -71,6 +73,10 @@ # ENT-4980 every batch "shard" record in Algolia should have all of these that pertain to the course EXPLORE_CATALOG_TITLES = ['A la carte', 'Subscription'] +# If enabled, query parameter used to fetch additional restricted course runs +# associated with a course +QUERY_FOR_RESTRICTED_RUNS = {'include_restricted': 'custom-b2b-enterprise'} + def _fetch_courses_by_keys(course_keys): """ @@ -81,7 +87,10 @@ def _fetch_courses_by_keys(course_keys): Returns: list of dict: Returns a list of dictionaries where each dictionary represents the course data from discovery. """ - return DiscoveryApiClient().fetch_courses_by_keys(course_keys) + additional_params = None + if settings.SHOULD_FETCH_RESTRICTED_COURSE_RUNS: + additional_params = QUERY_FOR_RESTRICTED_RUNS + return DiscoveryApiClient().fetch_courses_by_keys(course_keys, additional_params=additional_params) def _fetch_programs_by_keys(program_keys): @@ -285,6 +294,10 @@ def _update_full_content_metadata_course(content_keys, dry_run=False): logger.error('Could not find ContentMetadata record for content_key %s.', content_key) continue + # Before we do anything else, we need to prune the restricted + # course runs out of the recently fetch course metadata payload. + remove_restricted_course_runs(course_metadata_dict) + # Merge the full metadata from discovery's /api/v1/courses into the local metadata object. metadata_record.json_metadata.update(course_metadata_dict) diff --git a/enterprise_catalog/apps/api/v1/utils.py b/enterprise_catalog/apps/api/v1/utils.py index aead4a5f6..f1b45eeed 100644 --- a/enterprise_catalog/apps/api/v1/utils.py +++ b/enterprise_catalog/apps/api/v1/utils.py @@ -10,6 +10,10 @@ urlunsplit, ) +from enterprise_catalog.apps.catalog.content_metadata_utils import ( + is_course_run_active, +) + logger = logging.getLogger(__name__) @@ -59,25 +63,6 @@ def get_enterprise_utm_context(enterprise_name): return utm_context -def is_course_run_active(course_run): - """ - Checks whether a course run is active. That is, whether the course run is published, - enrollable, and marketable. - - Arguments: - course_run (dict): The metadata about a course run. - - Returns: - bool: True if course run is "active" - """ - course_run_status = course_run.get('status') or '' - is_published = course_run_status.lower() == 'published' - is_enrollable = course_run.get('is_enrollable', False) - is_marketable = course_run.get('is_marketable', False) - - return is_published and is_enrollable and is_marketable - - def is_any_course_run_active(course_runs): """ Iterates over all course runs to check if there any course run that is available for enrollment. diff --git a/enterprise_catalog/apps/api_client/discovery.py b/enterprise_catalog/apps/api_client/discovery.py index 3e4d5d644..b00ba26df 100644 --- a/enterprise_catalog/apps/api_client/discovery.py +++ b/enterprise_catalog/apps/api_client/discovery.py @@ -13,7 +13,7 @@ DISCOVERY_PROGRAM_KEY_BATCH_SIZE, ) from enterprise_catalog.apps.catalog.content_metadata_utils import ( - tansform_force_included_courses, + transform_force_included_courses, ) from enterprise_catalog.apps.catalog.utils import batch @@ -301,7 +301,7 @@ def get_metadata_by_query(self, catalog_query): f'attempting to force-include: {forced_aggregation_keys}' ) forced_courses = self.fetch_courses_by_keys(forced_aggregation_keys) - results += tansform_force_included_courses(forced_courses) + results += transform_force_included_courses(forced_courses) except Exception as exc: LOGGER.exception( f'unable to add unlisted courses for catalog_id: {catalog_query.id}' @@ -425,12 +425,14 @@ def get_programs(self, query_params=None): return programs - def fetch_courses_by_keys(self, course_keys): + def fetch_courses_by_keys(self, course_keys, additional_params=None): """ Fetches course data from discovery's /api/v1/courses endpoint for the provided course keys. Args: course_keys (list of str): Content keys for Course ContentMetadata objects. + additional_params (dict): Optional dict of additional query parameters + to send in request for course metadata. Returns: list of dict: Returns a list of dictionaries where each dictionary represents the course data from discovery. @@ -442,6 +444,7 @@ def fetch_courses_by_keys(self, course_keys): for course_keys_chunk in batched_course_keys: # Discovery expects the keys param to be in the format ?keys=course1,course2,... query_params = {'keys': ','.join(course_keys_chunk)} + query_params.update(additional_params or {}) courses.extend(self.get_courses(query_params=query_params)) return courses diff --git a/enterprise_catalog/apps/catalog/algolia_utils.py b/enterprise_catalog/apps/catalog/algolia_utils.py index 35daf5fc4..7cbdc8536 100644 --- a/enterprise_catalog/apps/catalog/algolia_utils.py +++ b/enterprise_catalog/apps/catalog/algolia_utils.py @@ -9,7 +9,6 @@ from django.utils.translation import gettext as _ from pytz import UTC -from enterprise_catalog.apps.api.v1.utils import is_course_run_active from enterprise_catalog.apps.api_client.algolia import AlgoliaSearchClient from enterprise_catalog.apps.api_client.constants import ( COURSE_REVIEW_BASE_AVG_REVIEW_SCORE, @@ -27,13 +26,17 @@ PROGRAM_TYPES_MAP, VIDEO, ) +from enterprise_catalog.apps.catalog.content_metadata_utils import ( + get_course_first_paid_enrollable_seat_price, + get_course_run_by_uuid, + is_course_run_active, +) from enterprise_catalog.apps.catalog.models import ContentMetadata from enterprise_catalog.apps.catalog.serializers import ( NormalizedContentMetadataSerializer, ) from enterprise_catalog.apps.catalog.utils import ( batch_by_pk, - get_course_run_by_uuid, localized_utcnow, to_timestamp, ) @@ -1277,33 +1280,6 @@ def _get_course_run_enroll_by_date_timestamp(normalized_content_metadata): return to_timestamp(enroll_by_date) -def get_course_first_paid_enrollable_seat_price(course): - """ - Gets the appropriate image to use for course cards. - - Arguments: - course (dict): a dictionary representing a course - - Returns: - str: the url for the course card image - """ - # Use advertised course run. - # If that fails use one of the other active course runs. (The latter is what Discovery does) - advertised_course_run = get_course_run_by_uuid(course, course.get('advertised_course_run_uuid')) - if advertised_course_run and advertised_course_run.get('first_enrollable_paid_seat_price'): - return advertised_course_run.get('first_enrollable_paid_seat_price') - - course_runs = course.get('course_runs') or [] - active_course_runs = [run for run in course_runs if is_course_run_active(run)] - for course_run in sorted( - active_course_runs, - key=lambda active_course_run: active_course_run['key'].lower(), - ): - if 'first_enrollable_paid_seat_price' in course_run: - return course_run['first_enrollable_paid_seat_price'] - return None - - def get_learning_type(content): """ Gets the content's learning type, checking and returning if the content @@ -1473,6 +1449,16 @@ def get_video_duration(video): return video.json_metadata.get('duration') +def _first_enrollable_paid_seat_price(course_record): + """ + Returns the course-level first_enrollable_paid_seat_price, + or computes it based on the course runs. + """ + if course_value := course_record.get('first_enrollable_paid_seat_price'): + return course_value + return get_course_first_paid_enrollable_seat_price(course_record) + + def _algolia_object_from_product(product, algolia_fields): """ Transforms a course or program into an Algolia object. @@ -1499,7 +1485,7 @@ def _algolia_object_from_product(product, algolia_fields): 'upcoming_course_runs': get_upcoming_course_runs(searchable_product), 'skill_names': get_course_skill_names(searchable_product), 'skills': get_course_skills(searchable_product), - 'first_enrollable_paid_seat_price': get_course_first_paid_enrollable_seat_price(searchable_product), + 'first_enrollable_paid_seat_price': _first_enrollable_paid_seat_price(searchable_product), 'original_image_url': get_course_original_image_url(searchable_product), 'marketing_url': get_course_marketing_url(searchable_product), 'outcome': get_course_outcome(searchable_product), diff --git a/enterprise_catalog/apps/catalog/constants.py b/enterprise_catalog/apps/catalog/constants.py index 9937bfbd7..eb76a0f28 100644 --- a/enterprise_catalog/apps/catalog/constants.py +++ b/enterprise_catalog/apps/catalog/constants.py @@ -121,6 +121,8 @@ COURSE_RUN_KEY_PREFIX = 'course-v1:' +COURSE_RUN_RESTRICTION_TYPE_KEY = 'restriction_type' + def json_serialized_course_modes(): """ diff --git a/enterprise_catalog/apps/catalog/content_metadata_utils.py b/enterprise_catalog/apps/catalog/content_metadata_utils.py index cc615ad08..e10ac2345 100644 --- a/enterprise_catalog/apps/catalog/content_metadata_utils.py +++ b/enterprise_catalog/apps/catalog/content_metadata_utils.py @@ -6,13 +6,16 @@ from enterprise_catalog.apps.catalog.utils import get_content_key -from .constants import FORCE_INCLUSION_METADATA_TAG_KEY +from .constants import ( + COURSE_RUN_RESTRICTION_TYPE_KEY, + FORCE_INCLUSION_METADATA_TAG_KEY, +) LOGGER = getLogger(__name__) -def tansform_force_included_courses(courses): +def transform_force_included_courses(courses): """ Transform a list of forced/unlisted course metadata ENT-8212 @@ -41,3 +44,124 @@ def transform_course_metadata_to_visible(course_metadata): course_run_statuses.append(course_run.get('status')) course_metadata['course_run_statuses'] = course_run_statuses return course_metadata + + +def get_course_run_by_uuid(course, course_run_uuid): + """ + Find a course_run based on uuid + + Arguments: + course (dict): course dict + course_run_uuid (str): uuid to lookup + + Returns: + dict: a course_run or None + """ + try: + course_run = [ + run for run in course.get('course_runs', []) + if run.get('uuid') == course_run_uuid + ][0] + except IndexError: + return None + return course_run + + +def is_course_run_active(course_run): + """ + Checks whether a course run is active. That is, whether the course run is published, + enrollable, and marketable. + + Arguments: + course_run (dict): The metadata about a course run. + + Returns: + bool: True if course run is "active" + """ + course_run_status = course_run.get('status') or '' + is_published = course_run_status.lower() == 'published' + is_enrollable = course_run.get('is_enrollable', False) + is_marketable = course_run.get('is_marketable', False) + + return is_published and is_enrollable and is_marketable + + +def get_course_first_paid_enrollable_seat_price(course): + """ + Arguments: + course (dict): a dictionary representing a course + + Returns: + The first enrollable paid seat price for the course. + """ + # Use advertised course run. + # If that fails use one of the other active course runs. + # (The latter is what Discovery does) + advertised_course_run = get_course_run_by_uuid(course, course.get('advertised_course_run_uuid')) + if advertised_course_run and advertised_course_run.get('first_enrollable_paid_seat_price'): + return advertised_course_run.get('first_enrollable_paid_seat_price') + + course_runs = course.get('course_runs') or [] + active_course_runs = [run for run in course_runs if is_course_run_active(run)] + for course_run in sorted( + active_course_runs, + key=lambda active_course_run: active_course_run['key'].lower(), + ): + if 'first_enrollable_paid_seat_price' in course_run: + return course_run['first_enrollable_paid_seat_price'] + return None + + +def find_restricted_course_runs(course_json_metadata): + """ + Filter to find and enumerate any restricted runs present in a dictionary + of course JSON metadata. + """ + found_restricted_runs = [] + for course_run in course_json_metadata.get('course_runs', []): + if course_run.get(COURSE_RUN_RESTRICTION_TYPE_KEY): + found_restricted_runs.append(course_run) + return found_restricted_runs + + +def remove_restricted_course_runs(course_json_metadata): + """ + Prevents restricted runs from being written to ContentMetadata.json_metadata before saving. + This includes removing the run from all run-based json keys: + * ContentMetadata.json_metadata["course_runs"] + * ContentMetadata.json_metadata["course_run_keys"] + * ContentMetadata.json_metadata["course_run_statuses"] + + It also updates the `first_enrollable_paid_seat_price` of the course after restricted runs + are removed. + """ + found_restricted_runs = find_restricted_course_runs(course_json_metadata) + if not found_restricted_runs: + return + + restricted_keys = {run['key'] for run in found_restricted_runs} + LOGGER.info( + '[restricted runs] Course %s has restricted runs %s that will be removed.', + course_json_metadata['key'], + restricted_keys, + ) + + non_restricted_keys = [] + non_restricted_runs = [] + non_restricted_statuses = set() + + for course_run in course_json_metadata['course_runs']: + if course_run['key'] not in restricted_keys: + non_restricted_keys.append(course_run['key']) + non_restricted_runs.append(course_run) + non_restricted_statuses.add(course_run['status']) + + course_json_metadata['course_runs'] = non_restricted_runs + course_json_metadata['course_run_keys'] = non_restricted_keys + course_json_metadata['course_run_statuses'] = sorted(list(non_restricted_statuses)) + + # also recompute the first enrollable paid seat price for the course + # now that we've removed any restricted runs + course_json_metadata['first_enrollable_paid_seat_price'] = get_course_first_paid_enrollable_seat_price( + course_json_metadata, + ) diff --git a/enterprise_catalog/apps/catalog/serializers.py b/enterprise_catalog/apps/catalog/serializers.py index b783813e2..5a2f5d394 100644 --- a/enterprise_catalog/apps/catalog/serializers.py +++ b/enterprise_catalog/apps/catalog/serializers.py @@ -10,7 +10,9 @@ from enterprise_catalog.apps.api.constants import CourseMode from enterprise_catalog.apps.catalog.constants import EXEC_ED_2U_COURSE_TYPE -from enterprise_catalog.apps.catalog.utils import get_course_run_by_uuid +from enterprise_catalog.apps.catalog.content_metadata_utils import ( + get_course_run_by_uuid, +) logger = logging.getLogger(__name__) diff --git a/enterprise_catalog/apps/catalog/tests/test_content_metadata_utils.py b/enterprise_catalog/apps/catalog/tests/test_content_metadata_utils.py index f3cb9e5ac..9893812f2 100644 --- a/enterprise_catalog/apps/catalog/tests/test_content_metadata_utils.py +++ b/enterprise_catalog/apps/catalog/tests/test_content_metadata_utils.py @@ -2,9 +2,14 @@ from django.test import TestCase +from enterprise_catalog.apps.catalog.constants import ( + COURSE_RUN_RESTRICTION_TYPE_KEY, +) from enterprise_catalog.apps.catalog.content_metadata_utils import ( - tansform_force_included_courses, + find_restricted_course_runs, + remove_restricted_course_runs, transform_course_metadata_to_visible, + transform_force_included_courses, ) @@ -33,7 +38,7 @@ def test_transform_course_metadata_to_visible(self): assert content_metadata['course_runs'][0]['availability'] == 'Current' assert content_metadata['course_run_statuses'][0] == 'published' - def test_tansform_force_included_courses(self): + def test_transform_force_included_courses(self): advertised_course_run_uuid = str(uuid4()) content_metadata = { 'advertised_course_run_uuid': advertised_course_run_uuid, @@ -49,5 +54,141 @@ def test_tansform_force_included_courses(self): ] } courses = [content_metadata] - tansform_force_included_courses(courses) + transform_force_included_courses(courses) assert courses[0]['course_runs'][0]['status'] == 'published' + + def test_find_restricted_runs(self): + course_metadata = { + 'course_runs': [ + { + 'key': 'the-normal-run', + 'uuid': uuid4(), + 'status': 'published', + }, + { + 'key': 'the-restricted-run', + 'uuid': uuid4(), + 'status': 'published', + COURSE_RUN_RESTRICTION_TYPE_KEY: 'custom-b2b-enterprise', + }, + { + 'key': 'the-other-restricted-run', + 'uuid': uuid4(), + 'status': 'published', + COURSE_RUN_RESTRICTION_TYPE_KEY: 'another-restriction-type', + }, + { + 'key': 'another-normal-run', + 'uuid': uuid4(), + 'status': 'published', + COURSE_RUN_RESTRICTION_TYPE_KEY: None, + }, + ] + } + + actual_restricted_runs = find_restricted_course_runs(course_metadata) + + expected_restricted_runs = [ + course_metadata['course_runs'][1], course_metadata['course_runs'][2], + ] + self.assertEqual(actual_restricted_runs, expected_restricted_runs) + + def test_find_restricted_runs_none_exist(self): + course_metadata = { + 'course_runs': [ + { + 'key': 'the-normal-run', + 'uuid': uuid4(), + 'status': 'published', + }, + { + 'key': 'another-normal-run', + 'uuid': uuid4(), + 'status': 'published', + COURSE_RUN_RESTRICTION_TYPE_KEY: None, + }, + ] + } + + actual_restricted_runs = find_restricted_course_runs(course_metadata) + + self.assertEqual(actual_restricted_runs, []) + + def test_remove_restricted_runs(self): + course_metadata = { + 'key': 'the-course-key', + 'advertised_course_run_uuid': 'advertised-run-uuid', + 'first_enrollable_paid_seat_price': 222, + 'course_run_statuses': ['published', 'unpublished'], + 'course_run_keys': [ + 'the-normal-run', + 'the-restricted-run', + 'the-other-restricted-run', + 'another-normal-run', + ], + 'course_runs': [ + { + 'key': 'the-normal-run', + 'uuid': 'advertised-run-uuid', + 'status': 'published', + 'is_enrollable': True, + 'is_marketable': True, + 'first_enrollable_paid_seat_price': 350, + }, + { + 'key': 'the-restricted-run', + 'uuid': uuid4(), + 'status': 'published', + COURSE_RUN_RESTRICTION_TYPE_KEY: 'custom-b2b-enterprise', + 'first_enrollable_paid_seat_price': 222, + }, + { + 'key': 'the-other-restricted-run', + 'uuid': uuid4(), + 'status': 'unpublished', + COURSE_RUN_RESTRICTION_TYPE_KEY: 'another-restriction-type', + }, + { + 'key': 'another-normal-run', + 'uuid': 'another-uuid', + 'status': 'published', + 'is_enrollable': True, + 'is_marketable': True, + 'first_enrollable_paid_seat_price': 0, + COURSE_RUN_RESTRICTION_TYPE_KEY: None, + }, + ] + } + + remove_restricted_course_runs(course_metadata) + + expected_transformation = { + 'key': 'the-course-key', + 'advertised_course_run_uuid': 'advertised-run-uuid', + 'first_enrollable_paid_seat_price': 350, + 'course_run_statuses': ['published'], + 'course_run_keys': [ + 'the-normal-run', + 'another-normal-run', + ], + 'course_runs': [ + { + 'key': 'the-normal-run', + 'uuid': 'advertised-run-uuid', + 'status': 'published', + 'is_enrollable': True, + 'is_marketable': True, + 'first_enrollable_paid_seat_price': 350, + }, + { + 'key': 'another-normal-run', + 'uuid': 'another-uuid', + 'status': 'published', + 'is_enrollable': True, + 'is_marketable': True, + 'first_enrollable_paid_seat_price': 0, + COURSE_RUN_RESTRICTION_TYPE_KEY: None, + }, + ] + } + self.assertEqual(expected_transformation, course_metadata) diff --git a/enterprise_catalog/apps/catalog/utils.py b/enterprise_catalog/apps/catalog/utils.py index 7a046424d..ada81ea98 100644 --- a/enterprise_catalog/apps/catalog/utils.py +++ b/enterprise_catalog/apps/catalog/utils.py @@ -158,21 +158,3 @@ def to_timestamp(datetime_str): except (ValueError, TypeError) as exc: LOGGER.error(f"[to_timestamp][{exc}] Could not parse date string: {datetime_str}") return None - - -def get_course_run_by_uuid(course, course_run_uuid): - """ - Find a course_run based on uuid - - Arguments: - course (dict): course dict - course_run_uuid (str): uuid to lookup - - Returns: - dict: a course_run or None - """ - try: - course_run = [run for run in course.get('course_runs', []) if run.get('uuid') == course_run_uuid][0] - except IndexError: - return None - return course_run diff --git a/enterprise_catalog/settings/base.py b/enterprise_catalog/settings/base.py index 6da799cb7..d51df1b7c 100644 --- a/enterprise_catalog/settings/base.py +++ b/enterprise_catalog/settings/base.py @@ -419,6 +419,10 @@ DEFAULT_COURSE_FIELDS_TO_PLUCK_FROM_SEARCH_ALL, ) +# Whether to fetch restricted course runs from the course-discovery +# /api/v1/courses endpoint +SHOULD_FETCH_RESTRICTED_COURSE_RUNS = False + # Set up system-to-feature roles mapping for edx-rbac SYSTEM_TO_FEATURE_ROLE_MAPPING = { # The enterprise catalog admin role is for users who need to perform state altering requests on catalogs