diff --git a/enterprise_catalog/apps/catalog/admin.py b/enterprise_catalog/apps/catalog/admin.py index cdb02aa4..9504824b 100644 --- a/enterprise_catalog/apps/catalog/admin.py +++ b/enterprise_catalog/apps/catalog/admin.py @@ -158,6 +158,9 @@ class RestrictedCourseMetadataAdmin(UnchangeableMixin): description='Catalog Query' ) def get_catalog_query_for_list(self, obj): + if not obj.catalog_query: + return None + link = reverse("admin:catalog_catalogquery_change", args=[obj.catalog_query.id]) return format_html('{}', link, obj.catalog_query.short_str_for_listings()) @@ -165,6 +168,9 @@ def get_catalog_query_for_list(self, obj): description='Catalog Query' ) def get_catalog_query(self, obj): + if not obj.catalog_query: + return None + link = reverse("admin:catalog_catalogquery_change", args=[obj.catalog_query.id]) return format_html('{}', link, obj.catalog_query.pretty_print_content_filter()) diff --git a/enterprise_catalog/apps/catalog/algolia_utils.py b/enterprise_catalog/apps/catalog/algolia_utils.py index 590c42b1..33b89212 100644 --- a/enterprise_catalog/apps/catalog/algolia_utils.py +++ b/enterprise_catalog/apps/catalog/algolia_utils.py @@ -10,7 +10,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, @@ -28,13 +27,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, ) @@ -1286,33 +1289,6 @@ def _get_course_run_enroll_start_date_timestamp(normalized_content_metadata): return to_timestamp(enroll_start_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 @@ -1482,6 +1458,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. @@ -1508,7 +1494,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 9937bfbd..58ed6e79 100644 --- a/enterprise_catalog/apps/catalog/constants.py +++ b/enterprise_catalog/apps/catalog/constants.py @@ -116,6 +116,9 @@ LATE_ENROLLMENT_THRESHOLD_DAYS = 30 RESTRICTED_RUNS_ALLOWED_KEY = 'restricted_runs_allowed' +COURSE_RUN_RESTRICTION_TYPE_KEY = 'restriction_type' +RESTRICTION_FOR_B2B = 'custom-b2b-enterprise' +QUERY_FOR_RESTRICTED_RUNS = {'include_restricted': RESTRICTION_FOR_B2B} AGGREGATION_KEY_PREFIX = 'course:' diff --git a/enterprise_catalog/apps/catalog/content_metadata_utils.py b/enterprise_catalog/apps/catalog/content_metadata_utils.py index cc615ad0..da894034 100644 --- a/enterprise_catalog/apps/catalog/content_metadata_utils.py +++ b/enterprise_catalog/apps/catalog/content_metadata_utils.py @@ -41,3 +41,64 @@ 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 diff --git a/enterprise_catalog/apps/catalog/models.py b/enterprise_catalog/apps/catalog/models.py index aac2fb10..d5f1c4a8 100644 --- a/enterprise_catalog/apps/catalog/models.py +++ b/enterprise_catalog/apps/catalog/models.py @@ -1,4 +1,5 @@ import collections +import copy import json from logging import getLogger from uuid import uuid4 @@ -20,7 +21,10 @@ get_most_recent_modified_time, update_query_parameters, ) -from enterprise_catalog.apps.api_client.discovery import CatalogQueryMetadata +from enterprise_catalog.apps.api_client.discovery import ( + CatalogQueryMetadata, + DiscoveryApiClient, +) from enterprise_catalog.apps.api_client.enterprise_cache import ( EnterpriseCustomerDetails, ) @@ -32,12 +36,17 @@ CONTENT_TYPE_CHOICES, COURSE, COURSE_RUN, + COURSE_RUN_RESTRICTION_TYPE_KEY, EXEC_ED_2U_COURSE_TYPE, EXEC_ED_2U_ENTITLEMENT_MODE, PROGRAM, + QUERY_FOR_RESTRICTED_RUNS, RESTRICTED_RUNS_ALLOWED_KEY, json_serialized_course_modes, ) +from enterprise_catalog.apps.catalog.content_metadata_utils import ( + get_course_first_paid_enrollable_seat_price, +) from enterprise_catalog.apps.catalog.utils import ( batch, enterprise_proxy_login_url, @@ -685,12 +694,10 @@ class Meta: @property def is_exec_ed_2u_course(self): - # pylint: disable=no-member return self.content_type == COURSE and self.json_metadata.get('course_type') == EXEC_ED_2U_COURSE_TYPE @property def aggregation_key(self): - # pylint: disable=no-member return self.json_metadata.get('aggregation_key') @classmethod @@ -818,7 +825,72 @@ def __str__(self): """ Return human-readable string representation. """ - return f"<{self.__class__.__name__} for '{self.content_key}' and CatalogQuery ({self.catalog_query.id})>" + catalog_query_id = self.catalog_query.id if self.catalog_query else None + return f"<{self.__class__.__name__} for '{self.content_key}' and CatalogQuery ({catalog_query_id})>" + + @classmethod + def _store_record(cls, course_metadata_dict, catalog_query=None): + """ + Given a course metadata dictionary, stores a corresponding + ``RestrictedContentMetadata`` record. Raises if the content key + is not of type 'course', or if a corresponding unrestricted parent + record cannot be found. + """ + content_type = course_metadata_dict.get('content_type') + if content_type != COURSE: + raise Exception('Can only store RestrictedContentMetadata with content type of course') + + course_key = course_metadata_dict['key'] + parent_record = ContentMetadata.objects.get(content_key=course_key, content_type=COURSE) + record, _ = cls.objects.update_or_create( + content_key=course_key, + content_uuid=course_metadata_dict['uuid'], + content_type=COURSE, + unrestricted_parent=parent_record, + catalog_query=catalog_query, + defaults={ + '_json_metadata': course_metadata_dict, + }, + ) + return record + + @classmethod + def store_canonical_record(cls, course_metadata_dict): + return cls._store_record(course_metadata_dict) + + @classmethod + def store_record_with_query(cls, course_metadata_dict, catalog_query): + filtered_metadata = cls.filter_restricted_runs(course_metadata_dict, catalog_query) + return cls._store_record(filtered_metadata, catalog_query) + + @classmethod + def filter_restricted_runs(cls, course_metadata_dict, catalog_query): + """ + Returns a copy of ``course_metadata_dict`` whose course_runs list + contains only unrestricted runs and restricted runs that are allowed + by the provided ``catalog_query``. + """ + filtered_metadata = copy.deepcopy(course_metadata_dict) + allowed_restricted_runs = catalog_query.restricted_runs_allowed.get(course_metadata_dict['key'], []) + + allowed_runs = [] + allowed_statuses = set() + allowed_keys = [] + + for run in filtered_metadata['course_runs']: + if run.get(COURSE_RUN_RESTRICTION_TYPE_KEY) is None or run['key'] in allowed_restricted_runs: + allowed_runs.append(run) + allowed_statuses.add(run['status']) + allowed_keys.append(run['key']) + + filtered_metadata['course_runs'] = allowed_runs + filtered_metadata['course_run_keys'] = allowed_keys + filtered_metadata['course_run_statuses'] = sorted(list(allowed_statuses)) + filtered_metadata['first_enrollable_paid_seat_price'] = get_course_first_paid_enrollable_seat_price( + filtered_metadata, + ) + + return filtered_metadata class RestrictedRunAllowedForRestrictedCourse(TimeStampedModel): @@ -1180,7 +1252,7 @@ def associate_content_metadata_with_query(metadata, catalog_query, dry_run=False metadata_list = create_content_metadata(metadata, catalog_query, dry_run) # Stop gap if the new metadata list is extremely different from the current one if _check_content_association_threshold(catalog_query, metadata_list): - return catalog_query.contentmetadata_set.values_list('content_key', flat=True) + return list(catalog_query.contentmetadata_set.values_list('content_key', flat=True)) # Setting `clear=True` will remove all prior relationships between # the CatalogQuery's associated ContentMetadata objects # before setting all new relationships from `metadata_list`. @@ -1307,29 +1379,65 @@ def update_contentmetadata_from_discovery(catalog_query, dry_run=False): LOGGER.exception(f'update_contentmetadata_from_discovery failed {catalog_query}') raise exc + if not metadata: + return [] + # associate content metadata with a catalog query only when we get valid results # back from the discovery service. if metadata is `None`, an error occurred while # calling discovery and we should not proceed with the below association logic. - if metadata: - metadata_content_keys = [get_content_key(entry) for entry in metadata] - LOGGER.info( - 'Retrieved %d content items (%d unique) from course-discovery for catalog query %s', - len(metadata_content_keys), - len(set(metadata_content_keys)), - catalog_query, - ) + metadata_content_keys = [get_content_key(entry) for entry in metadata] + LOGGER.info( + 'Retrieved %d content items (%d unique) from course-discovery for catalog query %s', + len(metadata_content_keys), + len(set(metadata_content_keys)), + catalog_query, + ) - associated_content_keys = associate_content_metadata_with_query(metadata, catalog_query, dry_run) - LOGGER.info( - 'Associated %d content items (%d unique) with catalog query %s', - len(associated_content_keys), - len(set(associated_content_keys)), - catalog_query, - ) + associated_content_keys = associate_content_metadata_with_query(metadata, catalog_query, dry_run) + LOGGER.info( + 'Associated %d content items (%d unique) with catalog query %s', + len(associated_content_keys), + len(set(associated_content_keys)), + catalog_query, + ) + + restricted_content_keys = synchronize_restricted_content(catalog_query, dry_run=dry_run) + return associated_content_keys + restricted_content_keys - return associated_content_keys - return [] +def synchronize_restricted_content(catalog_query, dry_run=False): + """ + Fetch and assoicate any permitted restricted couress for the given catalog_query. + """ + if not getattr(settings, 'SHOULD_FETCH_RESTRICTED_COURSE_RUNS', False): + return [] + + if not catalog_query.restricted_runs_allowed: + return [] + + restricted_course_keys = list(catalog_query.restricted_runs_allowed.keys()) + content_filter = { + 'content_type': 'course', + 'key': restricted_course_keys, + } + discovery_client = DiscoveryApiClient() + course_payload = discovery_client.retrieve_metadata_for_content_filter( + content_filter, QUERY_FOR_RESTRICTED_RUNS, + ) + + restricted_course_keys = [] + for course_dict in course_payload: + LOGGER.info('Storing restricted course %s for catalog_query %s', course_dict.get('key'), catalog_query.id) + if dry_run: + continue + + RestrictedCourseMetadata.store_canonical_record(course_dict) + restricted_course_record = RestrictedCourseMetadata.store_record_with_query( + course_dict, catalog_query, + ) + restricted_course_keys.append(restricted_course_record.content_key) + + return restricted_course_keys class CatalogUpdateCommandConfig(ConfigurationModel): diff --git a/enterprise_catalog/apps/catalog/serializers.py b/enterprise_catalog/apps/catalog/serializers.py index 13904c08..1238f670 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_models.py b/enterprise_catalog/apps/catalog/tests/test_models.py index c792f034..62abc470 100644 --- a/enterprise_catalog/apps/catalog/tests/test_models.py +++ b/enterprise_catalog/apps/catalog/tests/test_models.py @@ -14,14 +14,19 @@ from enterprise_catalog.apps.catalog.constants import ( COURSE, COURSE_RUN, + COURSE_RUN_RESTRICTION_TYPE_KEY, EXEC_ED_2U_COURSE_TYPE, EXEC_ED_2U_ENTITLEMENT_MODE, PROGRAM, + QUERY_FOR_RESTRICTED_RUNS, RESTRICTED_RUNS_ALLOWED_KEY, + RESTRICTION_FOR_B2B, ) from enterprise_catalog.apps.catalog.models import ( ContentMetadata, + RestrictedCourseMetadata, _should_allow_metadata, + synchronize_restricted_content, update_contentmetadata_from_discovery, ) from enterprise_catalog.apps.catalog.tests import factories @@ -1166,3 +1171,214 @@ def test_get_matching_content_with_restricted_runs( ] assert actual_json_metadata == expected_json_metadata assert actual_json_metadata_with_restricted == expected_json_metadata_with_restricted + + def test_store_canonical_record(self): + """ + Test that the canonical record is stored with all restricted runs. + """ + content_metadata_dict = { + 'key': 'edX+course', + 'uuid': '11111111-1111-1111-1111-111111111111', + 'content_type': COURSE, + 'course_runs': [ + { + 'key': 'course-v1:edX+course+run1', + 'is_restricted': False, + 'status': 'published', + }, + { + 'key': 'course-v1:edX+course+run2', + 'is_restricted': True, + 'status': 'unpublished', + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + }, + { + 'key': 'course-v1:edX+course+run3', + 'is_restricted': True, + 'status': 'other', + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + }, + ], + } + parent_record = factories.ContentMetadataFactory.create( + content_key='edX+course', + content_type=COURSE, + ) + + record = RestrictedCourseMetadata.store_canonical_record(content_metadata_dict) + + self.assertEqual(record.json_metadata['course_runs'], content_metadata_dict['course_runs']) + self.assertEqual(record.content_key, content_metadata_dict['key']) + self.assertEqual(record.content_uuid, content_metadata_dict['uuid']) + self.assertEqual(record.content_type, content_metadata_dict['content_type']) + self.assertEqual(record.unrestricted_parent, parent_record) + self.assertIsNone(record.catalog_query) + + def test_store_record_with_query(self): + """ + Tests that a restricted course to be associated with a particular query + stores only course run information for unrestricted courses and restricted + courses allowed by the query. + """ + catalog_query = factories.CatalogQueryFactory( + content_filter={ + 'restricted_runs_allowed': { + 'course:edX+course': [ + 'course-v1:edX+course+run2', + ], + }, + }, + ) + content_metadata_dict = { + 'key': 'edX+course', + 'uuid': '11111111-1111-1111-1111-111111111111', + 'content_type': COURSE, + 'course_runs': [ + { + 'key': 'course-v1:edX+course+run1', + 'is_restricted': False, + 'status': 'published', + }, + { + 'key': 'course-v1:edX+course+run2', + 'is_restricted': True, + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + 'status': 'unpublished', + }, + { + 'key': 'course-v1:edX+course+run3', + 'is_restricted': True, + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + 'status': 'other', + }, + ], + } + parent_record = factories.ContentMetadataFactory.create( + content_key='edX+course', + content_type=COURSE, + ) + + record = RestrictedCourseMetadata.store_record_with_query( + content_metadata_dict, + catalog_query, + ) + + self.assertEqual( + record.json_metadata['course_runs'], + [ + { + 'key': 'course-v1:edX+course+run1', + 'is_restricted': False, + 'status': 'published', + }, + { + 'key': 'course-v1:edX+course+run2', + 'is_restricted': True, + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + 'status': 'unpublished', + }, + ], + ) + self.assertEqual( + record.json_metadata['course_run_keys'], + ['course-v1:edX+course+run1', 'course-v1:edX+course+run2'], + ) + self.assertEqual( + record.json_metadata['course_run_statuses'], + ['published', 'unpublished'], + ) + self.assertEqual(record.content_key, content_metadata_dict['key']) + self.assertEqual(record.content_uuid, content_metadata_dict['uuid']) + self.assertEqual(record.content_type, content_metadata_dict['content_type']) + self.assertEqual(record.unrestricted_parent, parent_record) + self.assertEqual(record.catalog_query, catalog_query) + + @override_settings(SHOULD_FETCH_RESTRICTED_COURSE_RUNS=False) + @mock.patch('enterprise_catalog.apps.catalog.models.DiscoveryApiClient') + def test_synchronize_restricted_content_feature_disabled(self, mock_client): + result = synchronize_restricted_content(mock.ANY) + + self.assertEqual([], result) + self.assertFalse(mock_client.called) + + @override_settings(SHOULD_FETCH_RESTRICTED_COURSE_RUNS=True) + @mock.patch('enterprise_catalog.apps.catalog.models.DiscoveryApiClient') + def test_synchronize_restricted_content_query_has_no_restricted_content(self, mock_client): + catalog_query = factories.CatalogQueryFactory( + content_filter={'foo': 'bar'}, + ) + result = synchronize_restricted_content(catalog_query) + + self.assertEqual([], result) + self.assertFalse(mock_client.called) + + @override_settings(DISCOVERY_CATALOG_QUERY_CACHE_TIMEOUT=0) + @override_settings(SHOULD_FETCH_RESTRICTED_COURSE_RUNS=True) + @mock.patch('enterprise_catalog.apps.catalog.models.DiscoveryApiClient') + def test_synchronize_restricted_content(self, mock_client): + """ + Tests that ``synchronize_restricted_content()`` creates restricted + records. + """ + catalog_query = factories.CatalogQueryFactory( + content_filter={ + 'restricted_runs_allowed': { + 'course:edX+course': [ + 'course-v1:edX+course+run2', + ], + }, + }, + ) + content_metadata_dict = { + 'key': 'edX+course', + 'uuid': '11111111-1111-1111-1111-111111111111', + 'content_type': COURSE, + 'course_runs': [ + { + 'key': 'course-v1:edX+course+run1', + 'is_restricted': False, + 'status': 'published', + }, + { + 'key': 'course-v1:edX+course+run2', + 'is_restricted': True, + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + 'status': 'unpublished', + }, + { + 'key': 'course-v1:edX+course+run3', + 'is_restricted': True, + COURSE_RUN_RESTRICTION_TYPE_KEY: RESTRICTION_FOR_B2B, + 'status': 'other', + }, + ], + } + parent_record = factories.ContentMetadataFactory.create( + content_key='edX+course', + content_type=COURSE, + ) + mock_retrieve = mock_client.return_value.retrieve_metadata_for_content_filter + mock_retrieve.return_value = [ + content_metadata_dict, + ] + + result = synchronize_restricted_content(catalog_query) + + mock_retrieve.assert_called_once_with( + { + 'content_type': 'course', + 'key': ['edX+course'], + }, + QUERY_FOR_RESTRICTED_RUNS, + ) + self.assertEqual(result, [content_metadata_dict['key']]) + self.assertIsNotNone(RestrictedCourseMetadata.objects.get( + content_key=content_metadata_dict['key'], + unrestricted_parent=parent_record, + catalog_query=None, + )) + self.assertIsNotNone(RestrictedCourseMetadata.objects.get( + content_key=content_metadata_dict['key'], + unrestricted_parent=parent_record, + catalog_query=catalog_query, + )) diff --git a/enterprise_catalog/settings/base.py b/enterprise_catalog/settings/base.py index 11975811..f7a7cd34 100644 --- a/enterprise_catalog/settings/base.py +++ b/enterprise_catalog/settings/base.py @@ -420,6 +420,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