diff --git a/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py b/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py index c1be214b..f4b21bc3 100644 --- a/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py +++ b/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-10-08 22:19 +# Generated by Django 4.2.16 on 2024-10-09 03:45 import collections from django.conf import settings @@ -62,13 +62,13 @@ class Migration(migrations.Migration): field=jsonfield.fields.JSONField(default=dict, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'ensure_ascii': False, 'indent': 4, 'separators': (',', ':')}, help_text="Query parameters which will be used to filter the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}), ), migrations.CreateModel( - name='RestrictedRunsAllowedForRestrictedCourses', + name='RestrictedRunAllowedForRestrictedCourse', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('course', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_runs_allowed_for_restricted_courses', to='catalog.restrictedcoursemetadata')), - ('run', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_runs_allowed_for_restricted_courses', to='catalog.contentmetadata')), + ('course', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_run_allowed_for_restricted_course', to='catalog.restrictedcoursemetadata')), + ('run', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_run_allowed_for_restricted_course', to='catalog.contentmetadata')), ], options={ 'abstract': False, diff --git a/enterprise_catalog/apps/catalog/models.py b/enterprise_catalog/apps/catalog/models.py index 743df134..4df40166 100644 --- a/enterprise_catalog/apps/catalog/models.py +++ b/enterprise_catalog/apps/catalog/models.py @@ -402,12 +402,12 @@ def content_metadata(self): if not self.catalog_query: return ContentMetadata.objects.none() return self.catalog_query.contentmetadata_set.prefetch_related( - 'restricted_runs_allowed_for_restricted_courses' + 'restricted_run_allowed_for_restricted_course' ).filter( # Exclude all restricted runs (heuristic is that a run is assumed # restricted if it is mapped to a restricted course via - # RestrictedRunsAllowedForRestrictedCourses). - restricted_runs_allowed_for_restricted_courses__isnull=True, + # RestrictedRunAllowedForRestrictedCourse). + restricted_run_allowed_for_restricted_course__isnull=True, ) @property @@ -430,13 +430,17 @@ def content_metadata_with_restricted(self): """ if not self.catalog_query: return ContentMetadata.objects.none() - # FYI: prefetch causes a performance penalty by introducing a 2nd database query. - prefetch_qs = models.Prefetch( - 'restricted_courses', - queryset=RestrictedCourseMetadata.objects.filter(catalog_query=self.catalog_query), - to_attr='restricted_course_metadata_for_catalog_query', - ) - return self.catalog_query.contentmetadata_set.prefetch_related(prefetch_qs) + related_contentmetadata = self.catalog_query.contentmetadata_set + # Provide json_metadata overrides via dynamic attribute if any restricted runs are allowed. + if self.catalog_query.restricted_runs_allowed: + # FYI: prefetch causes a performance penalty by introducing a 2nd database query. + prefetch_qs = models.Prefetch( + 'restricted_courses', + queryset=RestrictedCourseMetadata.objects.filter(catalog_query=self.catalog_query), + to_attr='restricted_course_metadata_for_catalog_query', + ) + related_contentmetadata = related_contentmetadata.prefetch_related(prefetch_qs) + return related_contentmetadata.all() @cached_property def restricted_runs_allowed(self): @@ -540,23 +544,23 @@ def get_matching_content(self, content_keys, include_restricted=False): # parent content keys, i.e. course ids associated with the specified content_keys # (if any) to handle the following case: # - catalog contains courses and the specified content_keys are course run ids. - searched_metadata = ContentMetadata.objects.filter(content_key__in=content_keys).prefetch_related( - 'restricted_runs_allowed_for_restricted_courses' - ) - if include_restricted: + searched_metadata = ContentMetadata.objects.filter(content_key__in=content_keys) + if include_restricted and self.catalog_query.restricted_runs_allowed: # Only hide restricted runs that are not allowed by the current catalog. - searched_metadata = searched_metadata.exclude( + searched_metadata = searched_metadata.prefetch_related( + 'restricted_run_allowed_for_restricted_course' + ).exclude( # Find all restricted runs allowed by a RestrictedCourseMetadata related to the # current CatalogQuery. Do NOT exclude those. - ~Q(restricted_runs_allowed_for_restricted_courses__course__catalog_query=self.catalog_query) + ~Q(restricted_run_allowed_for_restricted_course__course__catalog_query=self.catalog_query) # Exclude all other restricted runs. A run is assumed of type restricted if it - # is related to at least one RestrictedRunsAllowedForRestrictedCourses. - & Q(restricted_runs_allowed_for_restricted_courses__isnull=False) + # is related to at least one RestrictedRunAllowedForRestrictedCourse. + & Q(restricted_run_allowed_for_restricted_course__isnull=False) ) else: # Hide ALL restricted runs. searched_metadata = searched_metadata.exclude( - restricted_runs_allowed_for_restricted_courses__isnull=False + restricted_run_allowed_for_restricted_course__isnull=False ) parent_content_keys = { metadata.parent_content_key @@ -964,7 +968,7 @@ class Meta: history = HistoricalRecords() -class RestrictedRunsAllowedForRestrictedCourses(TimeStampedModel): +class RestrictedRunAllowedForRestrictedCourse(TimeStampedModel): """ Mapping table to relate RestrictedCourseMetadata objects to restricted runs in ContentMetadata. @@ -978,14 +982,14 @@ class RestrictedRunsAllowedForRestrictedCourses(TimeStampedModel): RestrictedCourseMetadata, blank=False, null=True, - related_name='restricted_runs_allowed_for_restricted_courses', + related_name='restricted_run_allowed_for_restricted_course', on_delete=models.deletion.SET_NULL, ) run = models.ForeignKey( ContentMetadata, blank=False, null=True, - related_name='restricted_runs_allowed_for_restricted_courses', + related_name='restricted_run_allowed_for_restricted_course', on_delete=models.deletion.SET_NULL, ) diff --git a/enterprise_catalog/apps/catalog/tests/factories.py b/enterprise_catalog/apps/catalog/tests/factories.py index 6d9b29dd..4ea969ee 100644 --- a/enterprise_catalog/apps/catalog/tests/factories.py +++ b/enterprise_catalog/apps/catalog/tests/factories.py @@ -18,6 +18,8 @@ EnterpriseCatalog, EnterpriseCatalogFeatureRole, EnterpriseCatalogRoleAssignment, + RestrictedCourseMetadata, + RestrictedRunAllowedForRestrictedCourse, ) from enterprise_catalog.apps.core.models import User @@ -184,6 +186,31 @@ def _json_metadata(self): return json_metadata +class RestrictedCourseMetadataFactory(factory.django.DjangoModelFactory): + """ + Test factory for the `RestrictedCourseMetadata` model. + """ + class Meta: + model = RestrictedCourseMetadata + + content_key = factory.Faker('bothify', text='??????????+####') + content_uuid = factory.LazyFunction(uuid4) + content_type = COURSE + parent_content_key = None + _json_metadata = {} # Callers are encouraged to set this. + + +class RestrictedRunAllowedForRestrictedCourseFactory(factory.django.DjangoModelFactory): + """ + Test factory for the `RestrictedRunAllowedForRestrictedCourse` model. + """ + class Meta: + model = RestrictedRunAllowedForRestrictedCourse + + course = factory.SubFactory(RestrictedCourseMetadataFactory) + run = factory.SubFactory(ContentMetadataFactory) + + class UserFactory(factory.django.DjangoModelFactory): username = factory.Faker('user_name') password = factory.PostGenerationMethodCall('set_password', USER_PASSWORD) diff --git a/enterprise_catalog/apps/catalog/tests/test_models.py b/enterprise_catalog/apps/catalog/tests/test_models.py index 2f41b235..35c595c3 100644 --- a/enterprise_catalog/apps/catalog/tests/test_models.py +++ b/enterprise_catalog/apps/catalog/tests/test_models.py @@ -656,3 +656,213 @@ def test_restricted_runs_are_none(self, restricted_runs_dict): self.assertIsNone(catalog_query.restricted_runs_allowed) self.assertIsNone(catalog.restricted_runs_allowed) + + +@ddt.ddt +class TestRestrictedRunsModels(TestCase): + """ + Tests for the following models pertaining to the "restricted runs" feature: + * RestrictedCourseMetadata + * RestrictedRunAllowedForRestrictedCourse + """ + + @ddt.data( + # Skip creating any content metadata at all. The result should be empty. + { + 'create_catalog_query': { + '11111111-1111-1111-1111-111111111111': { + 'content_filter': {}, + }, + }, + 'expected_json_metadata': [], + 'expected_json_metadata_with_restricted': [], + }, + # Create a simple course and run, but it is not part of the catalog. The result should be empty. + { + 'create_catalog_query': { + '11111111-1111-1111-1111-111111111111': { + 'content_filter': {}, + }, + '22222222-2222-2222-2222-222222222222': { + 'content_filter': {}, + }, + }, + 'create_content_metadata': { + 'edX+course': { + 'create_runs': { + 'course-v1:edX+course+run1': {'is_restricted': False}, + }, + 'json_metadata': {'foobar': 'base metadata'}, + 'associate_with_catalog_query': '22222222-2222-2222-2222-222222222222', # different! + }, + }, + 'expected_json_metadata': [], + 'expected_json_metadata_with_restricted': [], + }, + # Create a simple course and run, associated with the catalog. + { + 'create_catalog_query': { + '11111111-1111-1111-1111-111111111111': { + 'content_filter': {}, + }, + }, + 'create_content_metadata': { + 'edX+course': { + 'create_runs': { + 'course-v1:edX+course+run1': {'is_restricted': False}, + }, + 'json_metadata': {'foobar': 'base metadata'}, + 'associate_with_catalog_query': '11111111-1111-1111-1111-111111111111', + }, + }, + 'expected_json_metadata': [ + {'foobar': 'base metadata'}, + ], + 'expected_json_metadata_with_restricted': [ + {'foobar': 'base metadata'}, + ], + }, + # Create a course with a restricted run, but the run is not allowed by the main CatalogQuery. + { + 'create_catalog_query': { + '11111111-1111-1111-1111-111111111111': { + 'content_filter': { + 'restricted_runs_allowed': { + 'course:edX+course': [ + 'course-v1:edX+course+run2', + ], + }, + }, + }, + '22222222-2222-2222-2222-222222222222': { + 'content_filter': {}, + }, + }, + 'create_content_metadata': { + 'edX+course': { + 'create_runs': { + 'course-v1:edX+course+run1': {'is_restricted': False}, + 'course-v1:edX+course+run2': {'is_restricted': True}, + }, + 'json_metadata': {'foobar': 'base metadata'}, + 'associate_with_catalog_query': '11111111-1111-1111-1111-111111111111', + }, + }, + 'create_restricted_courses': { + 0: { + 'content_key': 'edX+course', + 'catalog_query': '22222222-2222-2222-2222-222222222222', + 'json_metadata': {'foobar': 'override metadata'}, + }, + }, + 'create_restricted_run_allowed_for_restricted_course': [ + {'course': 0, 'run': 'course-v1:edX+course+run2'}, + ], + 'expected_json_metadata': [ + {'foobar': 'base metadata'}, + ], + 'expected_json_metadata_with_restricted': [ + {'foobar': 'base metadata'}, + ], + }, + # Create a course with a restricted run, and the run is allowed by the main CatalogQuery. + { + 'create_catalog_query': { + '11111111-1111-1111-1111-111111111111': { + 'content_filter': { + 'restricted_runs_allowed': { + 'course:edX+course': [ + 'course-v1:edX+course+run2', + ], + }, + }, + }, + }, + 'create_content_metadata': { + 'edX+course': { + 'create_runs': { + 'course-v1:edX+course+run1': {'is_restricted': False}, + 'course-v1:edX+course+run2': {'is_restricted': True}, + }, + 'json_metadata': {'foobar': 'base metadata'}, + 'associate_with_catalog_query': '11111111-1111-1111-1111-111111111111', + }, + }, + 'create_restricted_courses': { + 0: { + 'content_key': 'edX+course', + 'catalog_query': '11111111-1111-1111-1111-111111111111', + 'json_metadata': {'foobar': 'override metadata'}, + }, + }, + 'create_restricted_run_allowed_for_restricted_course': [ + {'course': 0, 'run': 'course-v1:edX+course+run2'}, + ], + 'expected_json_metadata': [ + {'foobar': 'base metadata'}, + ], + 'expected_json_metadata_with_restricted': [ + {'foobar': 'base metadata'}, + ], + }, + ) + @ddt.unpack + def test_catalog_content_metadata_with_restricted_runs( + self, + create_catalog_query, + create_content_metadata=None, + create_restricted_courses=None, + create_restricted_run_allowed_for_restricted_course=None, + expected_json_metadata=None, + expected_json_metadata_with_restricted=None, + ): + """ + """ + catalog_queries = { + cq_uuid: factories.CatalogQueryFactory( + uuid=cq_uuid, + content_filter=cq_info['content_filter'] | {'force_unique': cq_uuid}, + ) for cq_uuid, cq_info in create_catalog_query.items() + } + content_metadata = {} + create_content_metadata = create_content_metadata or {} + for course_key, course_info in create_content_metadata.items(): + course = factories.ContentMetadataFactory( + content_key=course_key, + content_type=COURSE, + _json_metadata=course_info['json_metadata'], + ) + content_metadata.update({course_key: course}) + if cq_uuid := course_info['associate_with_catalog_query']: + course.catalog_queries.set([catalog_queries[cq_uuid]]) + for run_key, run_info in course_info['create_runs'].items(): + run = factories.ContentMetadataFactory( + content_key=run_key, + content_type=COURSE_RUN, + ) + if run_info['is_restricted']: + # pylint: disable=protected-access + run._json_metadata.update({'restriction_type': 'custom-b2b-enterprise'}) + run.save() + content_metadata.update({run_key: run}) + restricted_courses = { + id: factories.RestrictedCourseMetadataFactory( + id=id, + content_key=restricted_course_info['content_key'], + _json_metadata=restricted_course_info['json_metadata'], + ) for id, restricted_course_info in create_restricted_courses.items() + } if create_restricted_courses else {} + for mapping_info in create_restricted_run_allowed_for_restricted_course or []: + factories.RestrictedRunAllowedForRestrictedCourseFactory( + course=restricted_courses[mapping_info['course']], + run=content_metadata[mapping_info['run']], + ) + catalog = factories.EnterpriseCatalogFactory( + catalog_query=catalog_queries['11111111-1111-1111-1111-111111111111'], + ) + expected_json_metadata = expected_json_metadata or [] + expected_json_metadata_with_restricted = expected_json_metadata_with_restricted or [] + actual_json_metadata = [m.json_metadata for m in catalog.content_metadata] + actual_json_metadata_with_restricted = [m.json_metadata for m in catalog.content_metadata_with_restricted] + assert actual_json_metadata == expected_json_metadata + assert actual_json_metadata_with_restricted == expected_json_metadata_with_restricted