From 045086fff128b6938e2aa4dc5663f8c32e35f663 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Thu, 26 Sep 2024 21:52:00 -0700 Subject: [PATCH 01/11] feat: Introduce models supporting restricted runs ENT-9569 --- .annotation_safe_list.yml | 2 + enterprise_catalog/apps/api/tasks.py | 29 +- .../apps/api/tests/test_tasks.py | 23 +- .../apps/api/v1/tests/test_serializers.py | 4 +- .../apps/api/v1/tests/test_utils.py | 6 +- ..._set_global_average_course_rating_value.py | 6 +- .../0040_restrictedcoursemetadata_and_more.py | 118 ++++ enterprise_catalog/apps/catalog/models.py | 389 +++++++++++-- enterprise_catalog/apps/catalog/tasks.py | 1 + .../apps/catalog/tests/factories.py | 29 +- .../apps/catalog/tests/test_algolia_utils.py | 20 +- .../apps/catalog/tests/test_models.py | 522 +++++++++++++++++- 12 files changed, 1071 insertions(+), 78 deletions(-) create mode 100644 enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 2a09ce7cb..67bd8f75e 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -21,6 +21,8 @@ catalog.HistoricalContentMetadata: ".. no_pii": "This model has no PII" catalog.HistoricalEnterpriseCatalog: ".. no_pii": "This model has no PII" +catalog.HistoricalRestrictedCourseMetadata: + ".. no_pii": "This model has no PII" contenttypes.ContentType: ".. no_pii:": "This model has no PII" curation.HistoricalEnterpriseCurationConfig: diff --git a/enterprise_catalog/apps/api/tasks.py b/enterprise_catalog/apps/api/tasks.py index aa466bb85..2fef38b24 100644 --- a/enterprise_catalog/apps/api/tasks.py +++ b/enterprise_catalog/apps/api/tasks.py @@ -287,7 +287,7 @@ def _update_full_content_metadata_course(content_keys, dry_run=False): else: ContentMetadata.objects.bulk_update( modified_content_metadata_records, - ['json_metadata'], + ['_json_metadata'], batch_size=10, ) @@ -326,13 +326,14 @@ def _update_single_full_course_record(course_metadata_dict, metadata_record, cou for that course. """ # Merge the full metadata from discovery's /api/v1/courses into the local metadata object. - metadata_record.json_metadata.update(course_metadata_dict) + metadata_record._json_metadata.update(course_metadata_dict) # pylint: disable=protected-access _normalize_metadata_record(metadata_record) if course_review: - metadata_record.json_metadata['reviews_count'] = course_review.get('reviews_count') - metadata_record.json_metadata['avg_course_rating'] = course_review.get('avg_course_rating') + # pylint: disable=protected-access + metadata_record._json_metadata['reviews_count'] = course_review.get('reviews_count') + metadata_record._json_metadata['avg_course_rating'] = course_review.get('avg_course_rating') if metadata_record.json_metadata.get(FORCE_INCLUSION_METADATA_TAG_KEY): metadata_record.json_metadata = transform_course_metadata_to_visible(metadata_record.json_metadata) @@ -358,11 +359,12 @@ def _normalize_metadata_record(course_metadata_record): normalized_metadata_input = { 'course_metadata': course_metadata_record.json_metadata, } - course_metadata_record.json_metadata['normalized_metadata'] =\ + # pylint: disable=protected-access + course_metadata_record._json_metadata['normalized_metadata'] =\ NormalizedContentMetadataSerializer(normalized_metadata_input).data - course_metadata_record.json_metadata['normalized_metadata_by_run'] = {} + course_metadata_record._json_metadata['normalized_metadata_by_run'] = {} for run in course_metadata_record.json_metadata.get('course_runs', []): - course_metadata_record.json_metadata['normalized_metadata_by_run'].update({ + course_metadata_record._json_metadata['normalized_metadata_by_run'].update({ run['key']: NormalizedContentMetadataSerializer({ 'course_run_metadata': run, 'course_metadata': course_metadata_record.json_metadata, @@ -413,7 +415,7 @@ def _update_full_content_metadata_program(content_keys, dry_run=False): logger.error('Could not find ContentMetadata record for content_key %s.', content_key) continue - metadata_record.json_metadata.update(program_metadata_dict) + metadata_record._json_metadata.update(program_metadata_dict) # pylint: disable=protected-access modified_content_metadata_records.append(metadata_record) if dry_run: @@ -421,7 +423,7 @@ def _update_full_content_metadata_program(content_keys, dry_run=False): else: ContentMetadata.objects.bulk_update( modified_content_metadata_records, - ['json_metadata'], + ['_json_metadata'], batch_size=10, ) @@ -1200,7 +1202,12 @@ def fetch_missing_course_metadata_task(self, force=False, dry_run=False): # pyl that are embedded inside a program. """ logger.info('[FETCH_MISSING_METADATA] fetch_missing_course_metadata_task task started.') - program_metadata_list = ContentMetadata.objects.filter(content_type=PROGRAM).values_list('json_metadata', flat=True) + program_metadata_list = ContentMetadata.objects.filter( + content_type=PROGRAM + ).values_list( + '_json_metadata', + flat=True, + ) course_keys = set() for program_metadata in program_metadata_list: if program_metadata is not None: @@ -1266,7 +1273,7 @@ def fetch_missing_pathway_metadata_task(self, force=False, dry_run=False): # py ) learner_pathway_metadata_list = ContentMetadata.objects.filter(content_type=LEARNER_PATHWAY).values_list( - 'json_metadata', flat=True, + '_json_metadata', flat=True, ) program_uuids = set() course_keys = set() diff --git a/enterprise_catalog/apps/api/tests/test_tasks.py b/enterprise_catalog/apps/api/tests/test_tasks.py index 0dfac25bd..1505bfa37 100644 --- a/enterprise_catalog/apps/api/tests/test_tasks.py +++ b/enterprise_catalog/apps/api/tests/test_tasks.py @@ -223,7 +223,7 @@ def test_fetch_missing_course_metadata_task(self, mock_update_data_from_discover """ test_course = 'course:edX+testX' course_content_metadata = ContentMetadataFactory.create(content_type=COURSE) - ContentMetadataFactory.create(content_type=PROGRAM, json_metadata={ + ContentMetadataFactory.create(content_type=PROGRAM, _json_metadata={ 'courses': [ course_content_metadata.json_metadata, { @@ -489,7 +489,7 @@ def test_update_full_metadata(self, mock_oauth_client, mock_partition_course_key # the normalized metadata serializer. metadata_4 = ContentMetadataFactory(content_type=COURSE, content_key=course_key_4) metadata_4.catalog_queries.set([self.catalog_query]) - metadata_4.json_metadata['advertised_course_run_uuid'] = None + metadata_4._json_metadata['advertised_course_run_uuid'] = None # pylint: disable=protected-access metadata_4.save() non_course_metadata = ContentMetadataFactory(content_type=COURSE_RUN, content_key=non_course_key) non_course_metadata.catalog_queries.set([self.catalog_query]) @@ -714,7 +714,7 @@ def test_update_full_metadata_exec_ed(self, mock_oauth_client, mock_partition_co # Simulate a pre-existing ContentMetadata object freshly seeded using the response from /api/v1/search/all/ course_metadata = ContentMetadataFactory.create( - content_type=COURSE, content_key=course_key, json_metadata={ + content_type=COURSE, content_key=course_key, _json_metadata={ 'aggregation_key': 'course:edX+testX', 'key': 'edX+testX', 'course_type': EXEC_ED_2U_COURSE_TYPE, @@ -801,7 +801,8 @@ def setUp(self): self.course_metadata_published.catalog_queries.set([self.enterprise_catalog_query]) self.course_metadata_published.tags.set([self.tag1]) self.course_metadata_unpublished = ContentMetadataFactory(content_type=COURSE, content_key='course-2') - self.course_metadata_unpublished.json_metadata.get('course_runs')[0].update({ + # pylint: disable=protected-access + self.course_metadata_unpublished._json_metadata.get('course_runs')[0].update({ 'status': 'unpublished', }) self.course_metadata_unpublished.catalog_queries.set([self.enterprise_catalog_query]) @@ -821,7 +822,7 @@ def setUp(self): content_type=COURSE_RUN, parent_content_key='course-2', ) - self.course_run_metadata_unpublished.json_metadata.update({ + self.course_run_metadata_unpublished._json_metadata.update({ 'status': 'unpublished', }) self.course_run_metadata_unpublished.catalog_queries.set([course_run_catalog_query]) @@ -874,7 +875,7 @@ def test_add_metadata_to_algolia_objects_skips_metadata_records_over_max_size(se short_description_string = "ayylmao".join(["" for x in range(50000)]) full_description_string = "foobar".join(["" for x in range(50000)]) too_big_sized_course = ContentMetadataFactory(content_type=COURSE, content_key='test-course-2') - too_big_sized_course.json_metadata.update( + too_big_sized_course._json_metadata.update( # pylint: disable=protected-access {"short_description": short_description_string, "full_description": full_description_string} ) too_big_sized_course.save() @@ -1216,7 +1217,7 @@ def test_index_algolia_published_course_to_program(self, mock_search_client): program_2 = ContentMetadataFactory(content_type=PROGRAM, content_key='program-2') # Make program-2 hidden to make it "non-indexable". Later we will assert that it will not get indexed. - program_2.json_metadata.update({ + program_2._json_metadata.update({ # pylint: disable=protected-access 'hidden': True, }) program_2.save() @@ -1324,7 +1325,7 @@ def test_index_algolia_unpublished_course_to_program(self, mock_search_client): program_2.catalog_queries.set([self.enterprise_catalog_query]) # Make program-2 hidden to make it "non-indexable". Later we will assert that it will not get indexed. - program_2.json_metadata.update({ + program_2._json_metadata.update({ # pylint: disable=protected-access 'hidden': True, }) program_2.save() @@ -1635,7 +1636,7 @@ def test_index_algolia_program_to_pathway(self, mock_search_client): program_1.catalog_queries.set([self.enterprise_catalog_query]) # Make program-2 hidden to make it "non-indexable". Later we will assert that it will not get indexed. - program_2.json_metadata.update({ + program_2._json_metadata.update({ # pylint: disable=protected-access 'hidden': True, }) program_2.save() @@ -1772,7 +1773,7 @@ def test_index_algolia_all_uuids(self, mock_search_client): program_for_main_course = ContentMetadataFactory(content_type=PROGRAM, content_key='program-1') # Make the program hidden to make it "non-indexable", but ensure that it still gets indexed due to being related # to an indexable course. - program_for_main_course.json_metadata.update({ + program_for_main_course._json_metadata.update({ # pylint: disable=protected-access 'hidden': True, }) program_for_main_course.save() @@ -1780,7 +1781,7 @@ def test_index_algolia_all_uuids(self, mock_search_client): program_for_pathway.catalog_queries.set([self.enterprise_catalog_query]) # Make the program hidden to make it "non-indexable", but ensure that it still gets indexed due to being related # to an indexable pathway. - program_for_pathway.json_metadata.update({ + program_for_pathway._json_metadata.update({ # pylint: disable=protected-access 'hidden': True, }) program_for_pathway.save() diff --git a/enterprise_catalog/apps/api/v1/tests/test_serializers.py b/enterprise_catalog/apps/api/v1/tests/test_serializers.py index 5c00c771f..bdcbcb192 100644 --- a/enterprise_catalog/apps/api/v1/tests/test_serializers.py +++ b/enterprise_catalog/apps/api/v1/tests/test_serializers.py @@ -29,9 +29,7 @@ def test_product_source_formatting(self): Test that the content metadata serializer will transform product source data within the json metadata field from a string to a dict. """ - json_metadata = self.content_metadata_item.json_metadata - json_metadata['product_source'] = '2u' - self.content_metadata_item.json_metadata = json_metadata + self.content_metadata_item._json_metadata.update({'product_source': '2u'}) # pylint: disable=protected-access self.content_metadata_item.save() serialized_data = ContentMetadataSerializer(self.content_metadata_item) assert serialized_data.data.get('product_source') == { diff --git a/enterprise_catalog/apps/api/v1/tests/test_utils.py b/enterprise_catalog/apps/api/v1/tests/test_utils.py index 2b0d99320..51185d1b2 100644 --- a/enterprise_catalog/apps/api/v1/tests/test_utils.py +++ b/enterprise_catalog/apps/api/v1/tests/test_utils.py @@ -69,10 +69,10 @@ def test_get_archived_content_count(self): """ Test that archived content will increment the count. """ - content_1 = ContentMetadataFactory.create(json_metadata={'course_run_statuses': ['archived']}) - content_2 = ContentMetadataFactory.create(json_metadata={'course_run_statuses': ['unpublished', 'archived']}) + content_1 = ContentMetadataFactory.create(_json_metadata={'course_run_statuses': ['archived']}) + content_2 = ContentMetadataFactory.create(_json_metadata={'course_run_statuses': ['unpublished', 'archived']}) # if there's at least one published course run, the content should not be considered archived - content_3 = ContentMetadataFactory.create(json_metadata={'course_run_statuses': ['published', 'archived']}) + content_3 = ContentMetadataFactory.create(_json_metadata={'course_run_statuses': ['published', 'archived']}) highlighted_content_1 = HighlightedContentFactory(content_metadata=content_1) highlighted_content_2 = HighlightedContentFactory(content_metadata=content_2) diff --git a/enterprise_catalog/apps/catalog/management/commands/tests/test_set_global_average_course_rating_value.py b/enterprise_catalog/apps/catalog/management/commands/tests/test_set_global_average_course_rating_value.py index bdc0b1b08..275c05bca 100644 --- a/enterprise_catalog/apps/catalog/management/commands/tests/test_set_global_average_course_rating_value.py +++ b/enterprise_catalog/apps/catalog/management/commands/tests/test_set_global_average_course_rating_value.py @@ -25,13 +25,13 @@ def test_command_averages_course_reviews( """ ContentMetadataFactory( content_type='course', - json_metadata={'avg_course_rating': 5, 'reviews_count': 20} + _json_metadata={'avg_course_rating': 5, 'reviews_count': 20} ) ContentMetadataFactory( content_type='course', - json_metadata={'avg_course_rating': 4, 'reviews_count': 10} + _json_metadata={'avg_course_rating': 4, 'reviews_count': 10} ) - ContentMetadataFactory(json_metadata={}) + ContentMetadataFactory(_json_metadata={}) call_command(self.command_name) expected_total_average = ((5 * 20) + (4 * 10)) / 30 diff --git a/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py b/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py new file mode 100644 index 000000000..f4b21bc31 --- /dev/null +++ b/enterprise_catalog/apps/catalog/migrations/0040_restrictedcoursemetadata_and_more.py @@ -0,0 +1,118 @@ +# Generated by Django 4.2.16 on 2024-10-09 03:45 + +import collections +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import jsonfield.encoder +import jsonfield.fields +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('catalog', '0039_alter_catalogquery_unique_together_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='RestrictedCourseMetadata', + 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')), + ('content_uuid', models.UUIDField(blank=True, help_text='The UUID that represents a piece of content. This value is usually a secondary identifier to content_key in the enterprise environment.', null=True, verbose_name='Content UUID')), + ('content_type', models.CharField(choices=[('course', 'Course'), ('courserun', 'Course Run'), ('program', 'Program'), ('learnerpathway', 'Learner Pathway')], max_length=255)), + ('parent_content_key', models.CharField(blank=True, db_index=True, help_text="The key represents this content's parent. For example for course_runs content their parent course key.", max_length=255, null=True)), + ('_json_metadata', jsonfield.fields.JSONField(blank=True, db_column='json_metadata', default={}, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'indent': 4, 'separators': (',', ':')}, help_text="The metadata about a particular piece content as retrieved from the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}, null=True)), + ('content_key', models.CharField(help_text='The key that represents a piece of content, such as a course, course run, or program.', max_length=255)), + ], + options={ + 'verbose_name': 'Restricted Content Metadata', + 'verbose_name_plural': 'Restricted Content Metadata', + }, + ), + migrations.AlterField( + model_name='contentmetadata', + name='json_metadata', + field=jsonfield.fields.JSONField(blank=True, db_column='json_metadata', default={}, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'indent': 4, 'separators': (',', ':')}, help_text="The metadata about a particular piece content as retrieved from the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}, null=True), + ), + migrations.RenameField( + model_name='contentmetadata', + old_name='json_metadata', + new_name='_json_metadata', + ), + migrations.AlterField( + model_name='historicalcontentmetadata', + name='json_metadata', + field=jsonfield.fields.JSONField(blank=True, db_column='json_metadata', default={}, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'indent': 4, 'separators': (',', ':')}, help_text="The metadata about a particular piece content as retrieved from the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}, null=True), + ), + migrations.RenameField( + model_name='historicalcontentmetadata', + old_name='json_metadata', + new_name='_json_metadata', + ), + migrations.AlterField( + model_name='catalogquery', + name='content_filter', + 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='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_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, + }, + ), + migrations.AddField( + model_name='restrictedcoursemetadata', + name='catalog_query', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_content_metadata', to='catalog.catalogquery'), + ), + migrations.AddField( + model_name='restrictedcoursemetadata', + name='unrestricted_parent', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restricted_courses', to='catalog.contentmetadata'), + ), + migrations.CreateModel( + name='HistoricalRestrictedCourseMetadata', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, 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')), + ('content_uuid', models.UUIDField(blank=True, help_text='The UUID that represents a piece of content. This value is usually a secondary identifier to content_key in the enterprise environment.', null=True, verbose_name='Content UUID')), + ('content_type', models.CharField(choices=[('course', 'Course'), ('courserun', 'Course Run'), ('program', 'Program'), ('learnerpathway', 'Learner Pathway')], max_length=255)), + ('parent_content_key', models.CharField(blank=True, db_index=True, help_text="The key represents this content's parent. For example for course_runs content their parent course key.", max_length=255, null=True)), + ('_json_metadata', jsonfield.fields.JSONField(blank=True, db_column='json_metadata', default={}, dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'indent': 4, 'separators': (',', ':')}, help_text="The metadata about a particular piece content as retrieved from the discovery service's search/all endpoint results, specified as a JSON object.", load_kwargs={'object_pairs_hook': collections.OrderedDict}, null=True)), + ('content_key', models.CharField(help_text='The key that represents a piece of content, such as a course, course run, or program.', max_length=255)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('catalog_query', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='catalog.catalogquery')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('unrestricted_parent', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='catalog.contentmetadata')), + ], + options={ + 'verbose_name': 'historical Restricted Content Metadata', + 'verbose_name_plural': 'historical Restricted Content Metadata', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AlterUniqueTogether( + name='restrictedcoursemetadata', + unique_together={('content_key', 'catalog_query')}, + ), + ] diff --git a/enterprise_catalog/apps/catalog/models.py b/enterprise_catalog/apps/catalog/models.py index ce4d163d9..584a1f9e4 100644 --- a/enterprise_catalog/apps/catalog/models.py +++ b/enterprise_catalog/apps/catalog/models.py @@ -1,3 +1,158 @@ +""" +SPIKE TEST: + +1. First, reset the sqlite database: + +rm enterprise_catalog/default.db && python manage.py migrate && python manage.py shell + +2. Then, paste everything below into the python shell: + +# Set up catalog and catalog query: +# ================================= +from enterprise_catalog.apps.catalog.models import * +from enterprise_catalog.apps.catalog.tests.factories import * +catalog = ( + EnterpriseCatalog.objects.filter(uuid='0fa8c2e9-ba23-4749-86d9-0bfa84991490').first() + or + EnterpriseCatalogFactory(uuid='0fa8c2e9-ba23-4749-86d9-0bfa84991490') +) +catalog_query = catalog.catalog_query +catalog_2 = ( + EnterpriseCatalog.objects.filter(uuid='2fa8c2e9-ba23-4749-86d9-0bfa84991490').first() + or + EnterpriseCatalogFactory(uuid='2fa8c2e9-ba23-4749-86d9-0bfa84991490') +) +catalog_query_2 = catalog_2.catalog_query + +# Set up pure course and runs: +# =============================== +course_pure = ( + ContentMetadata.objects.filter(content_key='course_pure').first() + or + ContentMetadataFactory(content_key='course_pure', content_type='course') +) +course_pure.catalog_queries.set([catalog_query]) +course_pure_run1 = ( + ContentMetadata.objects.filter(content_key='course_pure_run1').first() + or + ContentMetadataFactory( + content_key='course_pure_run1', + content_type='courserun', + parent_content_key=course_pure.content_key, + ) +) + +# Set up mixed course and runs: +# =============================== +course_mixed = ( + ContentMetadata.objects.filter(content_key='course_mixed').first() + or + ContentMetadataFactory(content_key='course_mixed', content_type='course') +) +course_mixed.catalog_queries.set([catalog_query, catalog_query_2]) +course_mixed_run1 = ( + ContentMetadata.objects.filter(content_key='course_mixed_run1').first() + or + ContentMetadataFactory( + content_key='course_mixed_run1', + content_type='courserun', + parent_content_key=course_mixed.content_key, + ) +) +course_mixed_run2 = ( + ContentMetadata.objects.filter(content_key='course_mixed_run2').first() + or + ContentMetadataFactory( + content_key='course_mixed_run2', + content_type='courserun', + parent_content_key=course_mixed.content_key, + ) +) +course_mixed_run3 = ( + ContentMetadata.objects.filter(content_key='course_mixed_run3').first() + or + ContentMetadataFactory( + content_key='course_mixed_run3', + content_type='courserun', + parent_content_key=course_mixed.content_key, + ) +) +restricted_course_mixed, _ = RestrictedCourseMetadata.objects.get_or_create( + content_key=course_mixed.content_key, + content_type=course_mixed.content_type, + catalog_query=catalog_query, + unrestricted_parent=course_mixed, + _json_metadata={'RESTRICTED': 'RESTRICTED_FOR_QUERY_1'}, +) +restricted_runs_allowed = [] +restricted_runs_allowed.append(RestrictedRunAllowedForRestrictedCourse.objects.get_or_create( + course=restricted_course_mixed, + run=course_mixed_run2, +)[0]) +restricted_course_mixed_2, _ = RestrictedCourseMetadata.objects.get_or_create( + content_key=course_mixed.content_key, + content_type=course_mixed.content_type, + catalog_query=catalog_query_2, + unrestricted_parent=course_mixed, + _json_metadata={'RESTRICTED': 'RESTRICTED_FOR_QUERY_2'}, +) +restricted_runs_allowed.append(RestrictedRunAllowedForRestrictedCourse.objects.get_or_create( + course=restricted_course_mixed_2, + run=course_mixed_run3, +)[0]) + +# Set up unicorn course and runs: +# =============================== +course_unicorn = ( + ContentMetadata.objects.filter(content_key='course_unicorn').first() + or + ContentMetadataFactory(content_key='course_unicorn', content_type='course') +) +course_unicorn.catalog_queries.set([catalog_query]) +course_unicorn_run1 = ( + ContentMetadata.objects.filter(content_key='course_unicorn_run1').first() + or + ContentMetadataFactory( + content_key='course_unicorn_run1', + content_type='courserun', + parent_content_key=course_unicorn.content_key, + ) +) +restricted_course_unicorn, _ = RestrictedCourseMetadata.objects.get_or_create( + content_key=course_unicorn.content_key, + content_type=course_unicorn.content_type, + catalog_query=catalog_query, + unrestricted_parent=course_unicorn, + _json_metadata={'RESTRICTED': 'RESTRICTED_FOR_QUERY_1'}, +) +restricted_runs_allowed.append(RestrictedRunAllowedForRestrictedCourse.objects.get_or_create( + course=restricted_course_unicorn, + run=course_unicorn_run1, +)[0]) + + +3. run this to delete all objects: + +all_objs = [ + catalog, + catalog_query, + catalog_2, + catalog_query_2, + course_pure, + course_pure_run1, + course_mixed, + course_mixed_run1, + course_mixed_run2, + course_mixed_run3, + restricted_course_mixed, + restricted_course_mixed_2, + course_unicorn, + course_unicorn_run1, + restricted_course_unicorn, +] + restricted_runs_allowed +for obj in all_objs: + obj.delete() +""" import collections import json from logging import getLogger @@ -252,11 +407,50 @@ def content_metadata(self): Helper to retrieve the content metadata associated with the catalog. Returns: - Queryset: The queryset of associated content metadata + Queryset of ContentMetadata: The queryset of associated content metadata + """ + if not self.catalog_query: + return ContentMetadata.objects.none() + return self.catalog_query.contentmetadata_set.prefetch_related( + '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 + # RestrictedRunAllowedForRestrictedCourse). + restricted_run_allowed_for_restricted_course__isnull=True, + ) + + @property + def content_metadata_with_restricted(self): + """ + Same as self.content_metadata, but dynamically replace course json_metadata + with the correct version containing restricted runs allowed by the current + catalog query. + + The technique to dynamically override ContentMetadata.json_metadata is a + combination of two things: + 1. This method setting the `restricted_course_metadata_for_catalog_query` + attribute in the queryset to get the correct RestrictedCourseMetadata, and + 2. The ContentMetdata.json_metadata attribute being a property that + dynamically uses (1) or falls back to the stored value in _json_metadata. + + Returns: + Queryset of ContentMetadata: Same as self.content_metadata, but courses may have augmented + json_metadata. """ if not self.catalog_query: return ContentMetadata.objects.none() - return self.catalog_query.contentmetadata_set.all() + 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): @@ -324,7 +518,7 @@ def get_catalog_content_diff(self, content_keys): items_not_found = distinct_content_keys - found_content_keys return [{'content_key': item} for item in items_not_found], items_not_included, items_found - def get_matching_content(self, content_keys): + def get_matching_content(self, content_keys, include_restricted=False): """ Returns the set of content contained within this catalog that matches any of the course keys, course run keys, or programs keys specified by @@ -361,15 +555,35 @@ def get_matching_content(self, 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) + 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.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_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 RestrictedRunAllowedForRestrictedCourse. + & Q(restricted_run_allowed_for_restricted_course__isnull=False) + ) + else: + # Hide ALL restricted runs. + searched_metadata = searched_metadata.exclude( + restricted_run_allowed_for_restricted_course__isnull=False + ) parent_content_keys = { metadata.parent_content_key for metadata in searched_metadata if metadata.parent_content_key } query |= Q(content_key__in=parent_content_keys) - return self.content_metadata.filter(query) + if include_restricted: + return self.content_metadata_with_restricted.filter(query) + else: + return self.content_metadata.filter(query) - def contains_content_keys(self, content_keys): + def contains_content_keys(self, content_keys, include_restricted=False): """ Determines whether the given ``content_keys`` are part of the catalog. @@ -386,10 +600,10 @@ def contains_content_keys(self, content_keys): in the ``content_keys`` list (to handle cases when a catalog contains only courses, but course run keys are provided in the ``content_keys`` argument). """ - included_content = self.get_matching_content(content_keys) + included_content = self.get_matching_content(content_keys, include_restricted=include_restricted) return included_content.exists() - def filter_content_keys(self, content_keys): + def filter_content_keys(self, content_keys, include_restricted=False): """ Determines whether content_keys are part of the catalog. @@ -419,7 +633,11 @@ def filter_content_keys(self, content_keys): query = Q(content_key__in=content_keys) | Q(parent_content_key__in=content_keys) items_included = set() - for content in self.content_metadata.filter(query).all(): + if include_restricted: + accessible_metadata_qs = self.content_metadata_with_restricted + else: + accessible_metadata_qs = self.content_metadata + for content in accessible_metadata_qs.filter(query).all(): if content.content_key in content_keys: items_included.add(content.content_key) elif content.parent_content_key in content_keys: @@ -568,13 +786,12 @@ def bulk_update(self, objs, fields, batch_size=None): super().bulk_update(objs, fields, batch_size=batch_size) -class ContentMetadata(TimeStampedModel): +class BaseContentMetadata(TimeStampedModel): """ - Stores the JSON metadata for a piece of content, such as a course, course run, or program. - The metadata is retrieved from the Discovery service /search/all endpoint. - - .. no_pii: + Common ContentMetadata fields. """ + class Meta: + abstract = True content_uuid = models.UUIDField( null=True, @@ -587,7 +804,6 @@ class ContentMetadata(TimeStampedModel): "in the enterprise environment." ) ) - content_key = models.CharField( max_length=255, blank=False, @@ -612,32 +828,22 @@ class ContentMetadata(TimeStampedModel): "The key represents this content's parent. For example for course_runs content their parent course key." ) ) - - # one course can be associated with many programs and one program can contain many courses. - associated_content_metadata = models.ManyToManyField('self', blank=True) - - json_metadata = JSONField( + _json_metadata = JSONField( default={}, blank=True, null=True, load_kwargs={'object_pairs_hook': collections.OrderedDict}, dump_kwargs={'indent': 4, 'cls': JSONEncoder, 'separators': (',', ':')}, + db_column='json_metadata', help_text=_( "The metadata about a particular piece content as retrieved from the discovery service's search/all " "endpoint results, specified as a JSON object." ) ) - catalog_queries = models.ManyToManyField(CatalogQuery) history = HistoricalRecords() - objects = ContentMetadataManager() - class Meta: - verbose_name = _("Content Metadata") - verbose_name_plural = _("Content Metadata") - app_label = 'catalog' - @property def is_exec_ed_2u_course(self): # pylint: disable=no-member @@ -666,6 +872,14 @@ def get_child_records(cls, content_metadata): """ return cls.objects.filter(parent_content_key=content_metadata.content_key) + @property + def json_metadata(self): + return self._json_metadata + + @json_metadata.setter + def json_metadata(self, new_json_metadata): + self._json_metadata = new_json_metadata + def __str__(self): """ Return human-readable string representation. @@ -677,6 +891,119 @@ def __str__(self): ) +class ContentMetadata(BaseContentMetadata): + """ + Stores the JSON metadata for a piece of content, such as a course, course run, or program. + The metadata is retrieved from the Discovery service /search/all endpoint. + + .. no_pii: + """ + class Meta: + verbose_name = _("Content Metadata") + verbose_name_plural = _("Content Metadata") + app_label = 'catalog' + + # one course can be associated with many programs and one program can contain many courses. + associated_content_metadata = models.ManyToManyField('self', blank=True) + + # one course can be part of many CatalogQueries and one CatalogQuery can contain many courses. + catalog_queries = models.ManyToManyField(CatalogQuery) + + history = HistoricalRecords() + + @property + def json_metadata(self): + """ + Use the CatalogQuery-specific version of a course json_metadata if one exists + (potentially containing restricted runs allowed by that CatatlogQuery), + otherwise fall back to the standard unrestricted-only version. + """ + restricted_course_metadata_for_catalog_query = getattr( + self, + 'restricted_course_metadata_for_catalog_query', + None, + ) + # Truthy means that the requester wants to see restricted runs AND restricted + # runs were actually found for this specific course and the requester's + # specific Catalog. + if restricted_course_metadata_for_catalog_query: + # pylint: disable=protected-access, unsubscriptable-object + return restricted_course_metadata_for_catalog_query[0]._json_metadata + return self._json_metadata + + @json_metadata.setter + def json_metadata(self, new_json_metadata): + self._json_metadata = new_json_metadata + + +class RestrictedCourseMetadata(BaseContentMetadata): + """ + Copies of courses, but one copy for each CatalogQuery which explicitly + allows any restricted runs of the course. + + .. no_pii: + """ + class Meta: + verbose_name = _("Restricted Content Metadata") + verbose_name_plural = _("Restricted Content Metadata") + app_label = 'catalog' + unique_together = ('content_key', 'catalog_query') + + # Overwrite content_key from BaseContentMetadata in order to change unique + # to False. Use unique_together to allow multiple copies of the same course + # (one for each catalog query. + content_key = models.CharField( + max_length=255, + blank=False, + null=False, + unique=False, + help_text=_( + "The key that represents a piece of content, such as a course, course run, or program." + ) + ) + unrestricted_parent = models.ForeignKey( + ContentMetadata, + blank=False, + null=True, + related_name='restricted_courses', + on_delete=models.deletion.SET_NULL, + ) + catalog_query = models.ForeignKey( + CatalogQuery, + blank=False, + null=True, + related_name='restricted_content_metadata', + on_delete=models.deletion.SET_NULL, + ) + history = HistoricalRecords() + + +class RestrictedRunAllowedForRestrictedCourse(TimeStampedModel): + """ + Mapping table to relate RestrictedCourseMetadata objects to restricted runs in ContentMetadata. + + A run should be mapped to a restricted course IFF the RestrictedCourseMetadata's + catalog query explicitly allows the run. This mapping table should be generated by the + update-content-metadata task. + + .. no_pii: + """ + course = models.ForeignKey( + RestrictedCourseMetadata, + blank=False, + null=True, + 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_run_allowed_for_restricted_course', + on_delete=models.deletion.SET_NULL, + ) + + def content_metadata_with_type_course(): """ Find all ContentMetadata records with a content type of "course". @@ -732,12 +1059,12 @@ def _get_defaults_from_metadata(entry, exists=False): if value := entry.get(field): entry_minimal[field] = value if entry_minimal: - defaults.update({'json_metadata': entry_minimal}) + defaults.update({'_json_metadata': entry_minimal}) elif not exists or (content_type != 'course'): # Update json_metadata for non-courses when ContentMetadata object already exists. Also, # always include json_metadata (regardless of content type) if ContentMetadata object # does not yet exist in the database. - defaults.update({'json_metadata': entry}) + defaults.update({'_json_metadata': entry}) return defaults @@ -790,9 +1117,9 @@ def _update_existing_content_metadata(existing_metadata_defaults, existing_metad content_metadata = existing_metadata_by_key.get(defaults['content_key']) if content_metadata: for key, value in defaults.items(): - if key == 'json_metadata': + if key == '_json_metadata': # merge new json_metadata with old json_metadata (i.e., don't replace it fully) - content_metadata.json_metadata.update(value) + content_metadata._json_metadata.update(value) # pylint: disable=protected-access else: # replace attributes with new values setattr(content_metadata, key, value) @@ -803,7 +1130,7 @@ def _update_existing_content_metadata(existing_metadata_defaults, existing_metad for metadata in metadata_list: LOGGER.info(f"[Dry Run] Skipping Content Metadata update: {metadata}") else: - metadata_fields_to_update = ['content_key', 'parent_content_key', 'content_type', 'json_metadata'] + metadata_fields_to_update = ['content_key', 'parent_content_key', 'content_type', '_json_metadata'] batch_size = settings.UPDATE_EXISTING_CONTENT_METADATA_BATCH_SIZE for batched_metadata in batch(metadata_list, batch_size=batch_size): try: diff --git a/enterprise_catalog/apps/catalog/tasks.py b/enterprise_catalog/apps/catalog/tasks.py index 82f0e4df8..e3785f4fa 100644 --- a/enterprise_catalog/apps/catalog/tasks.py +++ b/enterprise_catalog/apps/catalog/tasks.py @@ -17,6 +17,7 @@ @shared_task(base=LoggedTask) def compare_catalog_queries_to_filters_task(): logger.info('compare_catalog_queries_to_filters starting...') + # NOTE: No need to exclude restricted runs because they are already filtered out via content_type. for content_metadata in ContentMetadata.objects.filter(content_type=COURSE): for enterprise_catalog in EnterpriseCatalog.objects.all(): try: diff --git a/enterprise_catalog/apps/catalog/tests/factories.py b/enterprise_catalog/apps/catalog/tests/factories.py index d972bdd94..ca4445952 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 @@ -80,7 +82,7 @@ class Meta: parent_content_key = None @factory.lazy_attribute - def json_metadata(self): + def _json_metadata(self): json_metadata = { 'key': self.content_key, 'aggregation_key': f'{self.content_type}:{self.content_key}', @@ -187,6 +189,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_algolia_utils.py b/enterprise_catalog/apps/catalog/tests/test_algolia_utils.py index ced7a1da8..ad4c07332 100644 --- a/enterprise_catalog/apps/catalog/tests/test_algolia_utils.py +++ b/enterprise_catalog/apps/catalog/tests/test_algolia_utils.py @@ -126,7 +126,7 @@ def test_should_index_course( } course_metadata = ContentMetadataFactory.create( content_type=COURSE, - json_metadata=json_metadata, + _json_metadata=json_metadata, ) # pylint: disable=protected-access assert utils._should_index_course(course_metadata) is expected_result @@ -996,7 +996,7 @@ def test_get_program_skill_names(self, program_metadata, course_metadata, expect ContentMetadataFactory.create( content_key=program_metadata['courses'][0]['key'], content_type=COURSE, - json_metadata=course_metadata, + _json_metadata=course_metadata, ) skill_names = utils.get_program_skill_names(program_metadata) self.assertEqual(sorted(skill_names), sorted(expected_skill_names)) @@ -1269,7 +1269,7 @@ def test_get_program_subjects(self, program_metadata, course_metadata, expected_ ContentMetadataFactory.create( content_key=program_metadata['courses'][0]['key'], content_type=COURSE, - json_metadata=course_metadata, + _json_metadata=course_metadata, ) program_subjects = utils.get_program_subjects(program_metadata) self.assertEqual(sorted(expected_subjects), sorted(program_subjects)) @@ -1311,7 +1311,7 @@ def test_get_program_level_type(self, program_metadata, course_metadata, expecte ContentMetadataFactory.create( content_key=program_metadata['courses'][i]['key'], content_type=COURSE, - json_metadata=course_metadata[i], + _json_metadata=course_metadata[i], ) program_level_type = utils.get_program_level_type(program_metadata) self.assertEqual(expected_level_type, program_level_type) @@ -1420,12 +1420,12 @@ def test_get_pathway_availability(self, pathway_metadata, json_metadata, expecte ContentMetadataFactory.create( content_key=pathway_metadata['steps'][i]['courses'][i]['key'], content_type=COURSE, - json_metadata=json_metadata[i], + _json_metadata=json_metadata[i], ) ContentMetadataFactory.create( content_key=pathway_metadata['steps'][i]['programs'][i]['uuid'], content_type=PROGRAM, - json_metadata=json_metadata[i], + _json_metadata=json_metadata[i], ) pathway_availability = utils.get_pathway_availability(pathway_metadata) @@ -1526,12 +1526,12 @@ def test_get_pathway_partners(self, pathway_metadata, json_metadata, expected_pa ContentMetadataFactory.create( content_key=pathway_metadata['steps'][i]['courses'][i]['key'], content_type=COURSE, - json_metadata=json_metadata[0], + _json_metadata=json_metadata[0], ) ContentMetadataFactory.create( content_key=pathway_metadata['steps'][i]['programs'][i]['uuid'], content_type=PROGRAM, - json_metadata=json_metadata[1], + _json_metadata=json_metadata[1], ) pathway_partners = utils.get_pathway_partners(pathway_metadata) self.assertEqual(expected_partners, pathway_partners) @@ -1586,12 +1586,12 @@ def test_get_pathway_subjects(self, pathway_metadata, json_metadata, expected_su ContentMetadataFactory.create( content_key=pathway_metadata['steps'][i]['courses'][i]['key'], content_type=COURSE, - json_metadata=json_metadata[0], + _json_metadata=json_metadata[0], ) ContentMetadataFactory.create( content_key=pathway_metadata['steps'][i]['programs'][i]['uuid'], content_type=PROGRAM, - json_metadata=json_metadata[1], + _json_metadata=json_metadata[1], ) pathway_subjects = utils.get_pathway_subjects(pathway_metadata) self.assertEqual(sorted(expected_subjects), sorted(pathway_subjects)) diff --git a/enterprise_catalog/apps/catalog/tests/test_models.py b/enterprise_catalog/apps/catalog/tests/test_models.py index d9d69311c..91b18952d 100644 --- a/enterprise_catalog/apps/catalog/tests/test_models.py +++ b/enterprise_catalog/apps/catalog/tests/test_models.py @@ -44,7 +44,7 @@ def test_is_exec_ed_2u_course(self, content_type, course_type, expected_value): content_key='edX+testX', content_type=content_type, ) - content_metadata.json_metadata['course_type'] = course_type + content_metadata._json_metadata['course_type'] = course_type # pylint: disable=protected-access self.assertEqual(content_metadata.is_exec_ed_2u_course, expected_value) @ddt.data( @@ -60,7 +60,7 @@ def test__should_allow_metadata(self, content_type, course_type, expected_value) content_key='edX+testX', content_type=content_type, ) - content_metadata.json_metadata['course_type'] = course_type + content_metadata._json_metadata['course_type'] = course_type # pylint: disable=protected-access self.assertEqual(_should_allow_metadata(content_metadata.json_metadata), expected_value) @override_settings(DISCOVERY_CATALOG_QUERY_CACHE_TIMEOUT=0) @@ -479,7 +479,7 @@ def test_enrollment_url_exec_ed( content_key='the-content-key', content_type=COURSE, ) - content_metadata.json_metadata.update({ + content_metadata._json_metadata.update({ # pylint: disable=protected-access 'course_type': EXEC_ED_2U_COURSE_TYPE, 'entitlements': [{ 'mode': EXEC_ED_2U_ENTITLEMENT_MODE, @@ -584,9 +584,9 @@ def test_bulk_update_changes_modified_time(self): records = factories.ContentMetadataFactory.create_batch(10, modified=original_modified_time) for record in records: - record.json_metadata['extra_stuff'] = 'foo' + record._json_metadata['extra_stuff'] = 'foo' # pylint: disable=protected-access - ContentMetadata.objects.bulk_update(records, ['json_metadata'], batch_size=10) + ContentMetadata.objects.bulk_update(records, ['_json_metadata'], batch_size=10) for record in records: record.refresh_from_db() @@ -656,3 +656,515 @@ 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 both an unrestricted (run1) and restricted run (run2), and the restricted run is allowed + # by the 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': 'override metadata'}, + ], + }, + # Create a course with ONLY an unrestricted run (run1), and the restricted run is allowed by the 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': 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+run1'}, + ], + 'expected_json_metadata': [ + {'foobar': 'base metadata'}, + ], + 'expected_json_metadata_with_restricted': [ + {'foobar': 'override 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, + parent_content_key=course_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'], + unrestricted_parent=content_metadata[restricted_course_info['content_key']], + catalog_query=catalog_queries[restricted_course_info['catalog_query']], + _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 + + @ddt.data( + # Skip creating any content metadata at all. The result should be empty. + { + 'create_catalog_query': { + '11111111-1111-1111-1111-111111111111': { + 'content_filter': {}, + }, + }, + 'requested_content_keys': ['course-v1:edX+course+run1'], + '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! + }, + }, + 'requested_content_keys': ['course-v1:edX+course+run1'], + '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', + }, + }, + 'requested_content_keys': ['course-v1:edX+course+run1'], + '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', + # Not the caller's catalog! + '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'}, + ], + 'requested_content_keys': ['course-v1:edX+course+run2'], + 'expected_json_metadata': [ + # run2 was not found because it is restricted. + ], + 'expected_json_metadata_with_restricted': [ + # run2 was not found because it is not allowed by the caller's catalog. + ], + }, + # Create a course with both an unrestricted (run1) and restricted run (run2), and the + # restricted run is allowed by the CatalogQuery. Request the UNRESTRICTED run (run1) anyway + # and assert that the override course metadata is returned. + { + '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'}, + ], + 'requested_content_keys': ['course-v1:edX+course+run1'], + 'expected_json_metadata': [ + {'foobar': 'base metadata'}, + ], + 'expected_json_metadata_with_restricted': [ + # We supply the override course metadata *even though* the + # requested content key represents an unrestricted run. + {'foobar': 'override metadata'}, + ], + }, + # Create a course with ONLY an unrestricted run (run1), and the restricted run is allowed by the 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': 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+run1'}, + ], + 'requested_content_keys': ['course-v1:edX+course+run1'], + 'expected_json_metadata': [ + # The RUN is invisible to the requester because it is restricted, so the COURSE + # should not be found either. + ], + 'expected_json_metadata_with_restricted': [ + {'foobar': 'override metadata'}, + ], + }, + ) + @ddt.unpack + def test_get_matching_content_with_restricted_runs( + self, + create_catalog_query, + create_content_metadata=None, + create_restricted_courses=None, + create_restricted_run_allowed_for_restricted_course=None, + requested_content_keys=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, + parent_content_key=course_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'], + unrestricted_parent=content_metadata[restricted_course_info['content_key']], + catalog_query=catalog_queries[restricted_course_info['catalog_query']], + _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'], + ) + requested_content_keys = requested_content_keys or [] + 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.get_matching_content(requested_content_keys)] + actual_json_metadata_with_restricted = [ + m.json_metadata for m in catalog.get_matching_content(requested_content_keys, include_restricted=True) + ] + assert actual_json_metadata == expected_json_metadata + assert actual_json_metadata_with_restricted == expected_json_metadata_with_restricted From 699ec3aad96bf65ac7e397a55108ccde5dacd478 Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Tue, 8 Oct 2024 21:00:20 +0000 Subject: [PATCH 02/11] feat: adding v2 version of catalog-based get_content_metadata endpoint --- enterprise_catalog/apps/api/urls.py | 2 + enterprise_catalog/apps/api/v2/__init__.py | 0 .../apps/api/v2/tests/__init__.py | 0 enterprise_catalog/apps/api/v2/urls.py | 37 ++++++ .../apps/api/v2/views/__init__.py | 0 ...terprise_catalog_contains_content_items.py | 40 ++++++ ...enterprise_catalog_get_content_metadata.py | 18 +++ .../apps/api/v2/views/enterprise_customer.py | 123 ++++++++++++++++++ 8 files changed, 220 insertions(+) create mode 100644 enterprise_catalog/apps/api/v2/__init__.py create mode 100644 enterprise_catalog/apps/api/v2/tests/__init__.py create mode 100644 enterprise_catalog/apps/api/v2/urls.py create mode 100644 enterprise_catalog/apps/api/v2/views/__init__.py create mode 100644 enterprise_catalog/apps/api/v2/views/enterprise_catalog_contains_content_items.py create mode 100644 enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py create mode 100644 enterprise_catalog/apps/api/v2/views/enterprise_customer.py diff --git a/enterprise_catalog/apps/api/urls.py b/enterprise_catalog/apps/api/urls.py index 153b5f93d..097f4eea8 100644 --- a/enterprise_catalog/apps/api/urls.py +++ b/enterprise_catalog/apps/api/urls.py @@ -8,9 +8,11 @@ from django.urls import include, path from enterprise_catalog.apps.api.v1 import urls as v1_urls +from enterprise_catalog.apps.api.v2 import urls as v2_urls app_name = 'api' urlpatterns = [ path('v1/', include(v1_urls)), + path('v2/', include(v2_urls)), ] diff --git a/enterprise_catalog/apps/api/v2/__init__.py b/enterprise_catalog/apps/api/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/enterprise_catalog/apps/api/v2/tests/__init__.py b/enterprise_catalog/apps/api/v2/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/enterprise_catalog/apps/api/v2/urls.py b/enterprise_catalog/apps/api/v2/urls.py new file mode 100644 index 000000000..1ad6f2e08 --- /dev/null +++ b/enterprise_catalog/apps/api/v2/urls.py @@ -0,0 +1,37 @@ +""" +URL definitions for enterprise catalog API version 2. +""" +from django.urls import path, re_path +from rest_framework.routers import DefaultRouter + +from enterprise_catalog.apps.api.v2.views.enterprise_catalog_contains_content_items import ( + EnterpriseCatalogContainsContentItemsV2, +) +from enterprise_catalog.apps.api.v2.views.enterprise_catalog_get_content_metadata import ( + EnterpriseCatalogGetContentMetadataV2, +) +from enterprise_catalog.apps.api.v2.views.enterprise_customer import ( + EnterpriseCustomerViewSetV2, +) + + +app_name = 'v2' + +router = DefaultRouter() +router.register(r'enterprise-catalogs', EnterpriseCatalogContainsContentItemsV2, basename='enterprise-catalog-content-v2') +router.register(r'enterprise-customer', EnterpriseCustomerViewSetV2, basename='enterprise-customer-v2') + +urlpatterns = [ + re_path( + r'^enterprise-catalogs/(?P[\S]+)/get_content_metadata', + EnterpriseCatalogGetContentMetadataV2.as_view({'get': 'get'}), + name='get-content-metadata-v2' + ), + path( + 'enterprise-customer//content-metadata//', + EnterpriseCustomerViewSetV2.as_view({'get': 'content_metadata'}), + name='customer-content-metadata-retrieve-v2' + ), +] + +urlpatterns += router.urls diff --git a/enterprise_catalog/apps/api/v2/views/__init__.py b/enterprise_catalog/apps/api/v2/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/enterprise_catalog/apps/api/v2/views/enterprise_catalog_contains_content_items.py b/enterprise_catalog/apps/api/v2/views/enterprise_catalog_contains_content_items.py new file mode 100644 index 000000000..8e69b4f40 --- /dev/null +++ b/enterprise_catalog/apps/api/v2/views/enterprise_catalog_contains_content_items.py @@ -0,0 +1,40 @@ +""" +""" +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from rest_framework.decorators import action +from rest_framework.response import Response + +from enterprise_catalog.apps.api.constants import ( + CONTAINS_CONTENT_ITEMS_VIEW_CACHE_TIMEOUT_SECONDS, +) +from enterprise_catalog.apps.api.v1.decorators import ( + require_at_least_one_query_parameter, +) +from enterprise_catalog.apps.api.v1.utils import unquote_course_keys +from enterprise_catalog.apps.api.v1.views.enterprise_catalog_contains_content_items import ( + EnterpriseCatalogContainsContentItems, +) + + +class EnterpriseCatalogContainsContentItemsV2(EnterpriseCatalogContainsContentItems): + """ + View to determine if an enterprise catalog contains certain content + """ + @method_decorator(cache_page(CONTAINS_CONTENT_ITEMS_VIEW_CACHE_TIMEOUT_SECONDS)) + @method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids')) + @action(detail=True) + def contains_content_items(self, request, uuid, course_run_ids, program_uuids, **kwargs): # pylint: disable=unused-argument + """ + Returns whether or not the EnterpriseCatalog contains the specified content. + Multiple course_run_ids and/or program_uuids query parameters can be sent to this view to check for their + existence in the specified enterprise catalog. + """ + course_run_ids = unquote_course_keys(course_run_ids) + + enterprise_catalog = self.get_object() + contains_content_items = enterprise_catalog.contains_content_keys( + course_run_ids + program_uuids, + include_restricted=True, + ) + return Response({'contains_content_items': contains_content_items}) diff --git a/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py b/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py new file mode 100644 index 000000000..dc3f3ef30 --- /dev/null +++ b/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py @@ -0,0 +1,18 @@ +from enterprise_catalog.apps.api.v1.views.enterprise_catalog_get_content_metadata import EnterpriseCatalogGetContentMetadata + + +class EnterpriseCatalogGetContentMetadataV2(EnterpriseCatalogGetContentMetadata): + """ + View for retrieving all the content metadata associated with a catalog. + """ + def get_queryset(self, **kwargs): + """ + Returns all of the json of content metadata associated with the catalog. + """ + # Avoids ordering the content metadata by any field on that model to avoid using a temporary table / filesort + queryset = self.enterprise_catalog.content_metadata_with_restricted + content_filter = kwargs.get('content_keys_filter') + if content_filter: + queryset = self.enterprise_catalog.get_matching_content(content_keys=content_filter, include_restricted=True) + + return queryset.order_by('catalog_queries') diff --git a/enterprise_catalog/apps/api/v2/views/enterprise_customer.py b/enterprise_catalog/apps/api/v2/views/enterprise_customer.py new file mode 100644 index 000000000..8e4859934 --- /dev/null +++ b/enterprise_catalog/apps/api/v2/views/enterprise_customer.py @@ -0,0 +1,123 @@ +import logging +import uuid + +from rest_framework.exceptions import NotFound + +from enterprise_catalog.apps.api.v1.serializers import ContentMetadataSerializer +from enterprise_catalog.apps.api.v1.views.enterprise_customer import EnterpriseCustomerViewSet +from enterprise_catalog.apps.catalog.models import EnterpriseCatalog + +from django.utils.decorators import method_decorator +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST + +from enterprise_catalog.apps.api.v1.decorators import ( + require_at_least_one_query_parameter, +) +from enterprise_catalog.apps.api.v1.utils import unquote_course_keys + + +logger = logging.getLogger(__name__) + + +class EnterpriseCustomerViewSetV2(EnterpriseCustomerViewSet): + """ + Viewset for operations on enterprise customers. + Although we don't have a specific EnterpriseCustomer model, this viewset handles operations that use an enterprise + identifier to perform operations on their associated catalogs, etc. + """ + @method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids')) + @action(detail=True) + def contains_content_items(self, request, enterprise_uuid, course_run_ids, program_uuids, **kwargs): + """ + Returns whether or not the specified content is available for the given enterprise. + --- + parameters: + - name: course_run_ids + description: Ids of the course runs to check availability of + paramType: query + - name: program_uuids + description: Uuids of the programs to check availability of + paramType: query + - name: get_catalog_list + description: [Old parameter] Return a list of catalogs in which the course / program is present + paramType: query + - name: get_catalogs_containing_specified_content_ids + description: Return a list of catalogs in which the course / program is present + paramType: query + """ + get_catalogs_containing_specified_content_ids = request.GET.get( + 'get_catalogs_containing_specified_content_ids', False + ) + get_catalog_list = request.GET.get('get_catalog_list', False) + requested_course_or_run_keys = unquote_course_keys(course_run_ids) + + try: + uuid.UUID(enterprise_uuid) + except ValueError as exc: + logger.warning( + f"Could not parse catalogs from provided enterprise uuid: {enterprise_uuid}. " + f"Query failed with exception: {exc}" + ) + return Response( + f'Error: invalid enterprice customer uuid: "{enterprise_uuid}" provided.', + status=HTTP_400_BAD_REQUEST + ) + customer_catalogs = EnterpriseCatalog.objects.filter(enterprise_uuid=enterprise_uuid) + + any_catalog_contains_content_items = False + catalogs_that_contain_course = [] + for catalog in customer_catalogs: + contains_content_items = catalog.contains_content_keys(requested_course_or_run_keys + program_uuids, include_restricted=True) + if contains_content_items: + any_catalog_contains_content_items = True + if not (get_catalogs_containing_specified_content_ids or get_catalog_list): + # Break as soon as we find a catalog that contains the specified content + break + catalogs_that_contain_course.append(catalog.uuid) + + response_data = { + 'contains_content_items': any_catalog_contains_content_items, + } + if (get_catalogs_containing_specified_content_ids or get_catalog_list): + response_data['catalog_list'] = catalogs_that_contain_course + + return Response(response_data) + + def get_metadata_item_serializer(self): + """ + Gets the first matching serialized ContentMetadata for a requested ``content_identifier`` + associated with any of a requested ``customer_uuid``'s catalogs. + """ + enterprise_catalogs = list(EnterpriseCatalog.objects.filter( + enterprise_uuid=self.kwargs.get('enterprise_uuid') + )) + content_identifier = self.kwargs.get('content_identifier') + serializer_context = { + 'skip_customer_fetch': bool(self.request.query_params.get('skip_customer_fetch', '').lower()), + } + + try: + # Search for matching metadata if the value of the requested + # identifier is a valid UUID. + content_uuid = uuid.UUID(content_identifier) + for catalog in enterprise_catalogs: + content_with_uuid = catalog.content_metadata_with_restricted.filter(content_uuid=content_uuid) + if content_with_uuid: + return ContentMetadataSerializer( + content_with_uuid.first(), + context={'enterprise_catalog': catalog, **serializer_context}, + ) + except ValueError: + # Otherwise, search for matching metadata as a content key + for catalog in enterprise_catalogs: + content_with_key = catalog.get_matching_content(content_keys=[content_identifier], include_restricted=True) + if content_with_key: + return ContentMetadataSerializer( + content_with_key.first(), + context={'enterprise_catalog': catalog, **serializer_context}, + ) + # If we've made it here without finding a matching ContentMetadata record, + # assume no matching record exists and raise a 404. + raise NotFound(detail='No matching content in any catalog for this customer') From 625e883adab109359bb8c015ddfb71caaff7a8ec Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Tue, 8 Oct 2024 21:12:33 +0000 Subject: [PATCH 03/11] feat: adding tests --- .../apps/api/v2/tests/test_views.py | 2307 +++++++++++++++++ 1 file changed, 2307 insertions(+) create mode 100644 enterprise_catalog/apps/api/v2/tests/test_views.py diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py new file mode 100644 index 000000000..f3fd76985 --- /dev/null +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -0,0 +1,2307 @@ +import copy +import json +import uuid +from collections import OrderedDict +from datetime import datetime +from unittest import mock +from urllib.parse import urljoin + +import ddt +import pytz +from django.conf import settings +from django.db import IntegrityError +from django.utils.http import urlencode +from django.utils.text import slugify +from rest_framework import status +from rest_framework.reverse import reverse +from rest_framework.settings import api_settings +from six.moves.urllib.parse import quote_plus + +from enterprise_catalog.apps.academy.tests.factories import ( + AcademyFactory, + TagFactory, +) +from enterprise_catalog.apps.api.v1.serializers import ContentMetadataSerializer +from enterprise_catalog.apps.api.v1.tests.mixins import APITestMixin +from enterprise_catalog.apps.api.v1.utils import is_any_course_run_active +from enterprise_catalog.apps.catalog.constants import ( + COURSE, + COURSE_RUN, + EXEC_ED_2U_COURSE_TYPE, + EXEC_ED_2U_ENTITLEMENT_MODE, + LEARNER_PATHWAY, + PROGRAM, + SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, +) +from enterprise_catalog.apps.catalog.models import ( + ContentMetadata, + EnterpriseCatalog, +) +from enterprise_catalog.apps.catalog.tests.factories import ( + CatalogQueryFactory, + ContentMetadataFactory, + EnterpriseCatalogFactory, +) +from enterprise_catalog.apps.catalog.utils import ( + enterprise_proxy_login_url, + get_content_filter_hash, + get_content_key, + get_parent_content_key, + localized_utcnow, +) +from enterprise_catalog.apps.video_catalog.tests.factories import ( + VideoFactory, + VideoSkillFactory, + VideoTranscriptSummaryFactory, +) + + +@ddt.ddt +class EnterpriseCatalogDefaultCatalogResultsTests(APITestMixin): + """ + Tests for the DefaultCatalogResultsView class + """ + mock_algolia_hits = {'hits': [{ + 'aggregation_key': 'course:MITx+18.01.2x', + 'key': 'MITx+18.01.2x', + 'language': 'English', + 'transcript_languages': ['English', 'Arabic'], + 'level_type': 'Intermediate', + 'content_type': 'course', + 'partners': [ + {'name': 'Massachusetts Institute of Technology', + 'logo_image_url': 'https://edx.org/image.png'} + ], + 'programs': ['Professional Certificate'], + 'program_titles': ['Totally Awesome Program'], + 'short_description': 'description', + 'subjects': ['Math'], + 'skills': [{ + 'name': 'Probability And Statistics', + 'description': 'description' + }, { + 'name': 'Engineering Design Process', + 'description': 'description' + }], + 'title': 'Calculus 1B: Integration', + 'marketing_url': 'edx.org/foo-bar', + 'first_enrollable_paid_seat_price': 100, + 'advertised_course_run': { + 'key': 'MITx/18.01.2x/3T2015', + 'pacing_type': 'instructor_paced', + 'start': '2015-09-08T00:00:00Z', + 'end': '2015-09-08T00:00:01Z', + 'upgrade_deadline': 32503680000.0, + }, + 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf8-catalog-query-uuids-0' + }, + { + 'aggregation_key': 'course:MITx+19', + 'key': 'MITx+19', + 'language': 'English', + 'transcript_languages': ['English', 'Arabic'], + 'level_type': 'Intermediate', + 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf9-catalog-query-uuids-0' + }, + { + 'aggregation_key': 'course:MITx+20', + 'language': 'English', + 'transcript_languages': ['English', 'Arabic'], + 'level_type': 'Intermediate', + 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf7-catalog-query-uuids-0' + } + ]} + + def setUp(self): + super().setUp() + self.set_up_staff_user() + + def _get_contains_content_base_url(self): + """ + Helper to construct the base url for the contains_content_items endpoint + """ + return reverse('api:v1:default-course-set') + + def test_facet_validation(self): + """ + Tests that the view validates Algolia facets provided by query params + """ + url = self._get_contains_content_base_url() + invalid_facets = 'invalid_facet=wrong&enterprise_catalog_query_titles=ayylmao' + response = self.client.get(f'{url}?{invalid_facets}') + assert response.status_code == 400 + assert response.json() == {'Error': "invalid facet(s): ['invalid_facet'] provided."} + + @mock.patch('enterprise_catalog.apps.api.v1.views.default_catalog_results.get_initialized_algolia_client') + def test_valid_facet_validation(self, mock_algolia_client): + """ + Tests a successful request with facets. + """ + mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] + url = self._get_contains_content_base_url() + facets = 'enterprise_catalog_query_titles=foo&content_type=course' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 200 + + @mock.patch('enterprise_catalog.apps.api.v1.views.default_catalog_results.get_initialized_algolia_client') + def test_default_catalog_results_view_works_with_one_and_many_course_types(self, mock_algolia_client): + """ + Test that the default catalog results view rejects requests where the query param course_type is not a list + """ + mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] + url = self._get_contains_content_base_url() + facets = 'enterprise_catalog_query_titles=foo&course_type=course' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 200 + + facets = 'enterprise_catalog_query_titles=foo&course_type=course&course_type=notcourse' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 200 + + +@ddt.ddt +class EnterpriseCatalogCRUDViewSetTests(APITestMixin): + """ + Tests for the EnterpriseCatalogCRUDViewSet + """ + + def setUp(self): + super().setUp() + self.set_up_staff() + self.enterprise_catalog = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + enterprise_name=self.enterprise_name, + ) + self.new_catalog_uuid = uuid.uuid4() + self.new_catalog_data = { + 'uuid': self.new_catalog_uuid, + 'title': 'Test Title', + 'enterprise_customer': self.enterprise_uuid, + 'enterprise_customer_name': self.enterprise_name, + 'enabled_course_modes': ['verified'], + 'publish_audit_enrollment_urls': True, + 'content_filter': {'content_type': 'course'}, + } + + def _assert_correct_new_catalog_data(self, catalog_uuid): + """ + Helper for verifying the data for a created/updated catalog + """ + new_enterprise_catalog = EnterpriseCatalog.objects.get(uuid=catalog_uuid) + self.assertEqual(new_enterprise_catalog.title, self.new_catalog_data['title']) + self.assertEqual(new_enterprise_catalog.enabled_course_modes, ['verified']) + self.assertEqual( + new_enterprise_catalog.publish_audit_enrollment_urls, + self.new_catalog_data['publish_audit_enrollment_urls'], + ) + self.assertEqual( + new_enterprise_catalog.catalog_query.content_filter, + OrderedDict([('content_type', 'course')]), + ) + + def test_detail_unauthorized_catalog_learner(self): + """ + Verify the viewset rejects catalog learners for the detail route + """ + self.set_up_catalog_learner() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_patch_unauthorized_catalog_learner(self): + """ + Verify the viewset rejects patch for catalog learners + """ + self.set_up_catalog_learner() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + patch_data = {'title': 'Patch title'} + response = self.client.patch(url, patch_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_put_unauthorized_catalog_learner(self): + """ + Verify the viewset rejects put for catalog learners + """ + self.set_up_catalog_learner() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.put(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_post_unauthorized_catalog_learner(self): + """ + Verify the viewset rejects post for catalog learners + """ + self.set_up_catalog_learner() + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.post(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @ddt.data( + (False), + (True), + ) + def test_detail(self, is_implicit_check): + """ + Verify the viewset returns the details for a single enterprise catalog + """ + if is_implicit_check: + self.remove_role_assignments() + + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(uuid.UUID(data['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(data['title'], self.enterprise_catalog.title) + self.assertEqual(uuid.UUID(data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) + + def test_detail_provisioning_admin(self): + """ + Verify the viewset returns the details if requesting user is a PA + """ + self.set_up_staff_user() + self.remove_role_assignments() + self.set_up_invalid_jwt_role() + self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(uuid.UUID(data['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(data['title'], self.enterprise_catalog.title) + self.assertEqual(uuid.UUID(data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) + + def test_detail_unauthorized_non_catalog_admin(self): + """ + Verify the viewset rejects users that are not catalog admins for the detail route + """ + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_detail_unauthorized_incorrect_jwt_context(self): + """ + Verify the viewset rejects users that are catalog admins with an invalid + context (i.e., enterprise uuid) for the detail route. + """ + enterprise_catalog = EnterpriseCatalogFactory() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog.uuid}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @ddt.data( + (False), + (True), + ) + def test_patch(self, is_implicit_check): + """ + Verify the viewset handles patching an enterprise catalog + """ + if is_implicit_check: + self.remove_role_assignments() + + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + patch_data = {'title': 'Patch title'} + response = self.client.patch(url, patch_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Verify that only the data we specifically patched changed + self.assertEqual(response.data['title'], patch_data['title']) + patched_catalog = EnterpriseCatalog.objects.get(uuid=self.enterprise_catalog.uuid) + self.assertEqual(patched_catalog.catalog_query, self.enterprise_catalog.catalog_query) + self.assertEqual(patched_catalog.enterprise_uuid, self.enterprise_catalog.enterprise_uuid) + self.assertEqual(patched_catalog.enabled_course_modes, self.enterprise_catalog.enabled_course_modes) + self.assertEqual( + patched_catalog.publish_audit_enrollment_urls, + self.enterprise_catalog.publish_audit_enrollment_urls, + ) + + def test_patch_provisioning_admins(self): + """ + Verify the viewset handles patching an enterprise catalog + """ + self.remove_role_assignments() + self.set_up_invalid_jwt_role() + self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + patch_data = {'title': 'Patch title'} + response = self.client.patch(url, patch_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Verify that only the data we specifically patched changed + self.assertEqual(response.data['title'], patch_data['title']) + patched_catalog = EnterpriseCatalog.objects.get(uuid=self.enterprise_catalog.uuid) + self.assertEqual(patched_catalog.catalog_query, self.enterprise_catalog.catalog_query) + self.assertEqual(patched_catalog.enterprise_uuid, self.enterprise_catalog.enterprise_uuid) + self.assertEqual(patched_catalog.enabled_course_modes, self.enterprise_catalog.enabled_course_modes) + self.assertEqual( + patched_catalog.publish_audit_enrollment_urls, + self.enterprise_catalog.publish_audit_enrollment_urls, + ) + + def test_patch_unauthorized_non_catalog_admin(self): + """ + Verify the viewset rejects patch for users that are not catalog admins + """ + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + patch_data = {'title': 'Patch title'} + response = self.client.patch(url, patch_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_patch_unauthorized_incorrect_jwt_context(self): + """ + Verify the viewset rejects patch for users that are catalog admins with an invalid + context (i.e., enterprise uuid) + """ + enterprise_catalog = EnterpriseCatalogFactory() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog.uuid}) + patch_data = {'title': 'Patch title'} + response = self.client.patch(url, patch_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @ddt.data( + (False), + (True), + ) + def test_put(self, is_implicit_check): + """ + Verify the viewset handles replacing an enterprise catalog + """ + if is_implicit_check: + self.remove_role_assignments() + + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.put(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self._assert_correct_new_catalog_data(self.enterprise_catalog.uuid) # The UUID should not have changed + + def test_put_provisioning_admins(self): + """ + Verify the viewset allows access to PAs + """ + self.set_up_staff_user() + self.remove_role_assignments() + self.set_up_invalid_jwt_role() + self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.put(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self._assert_correct_new_catalog_data(self.enterprise_catalog.uuid) # The UUID should not have changed + + def test_put_unauthorized_non_catalog_admin(self): + """ + Verify the viewset rejects put for users that are not catalog admins + """ + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.put(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_put_unauthorized_incorrect_jwt_context(self): + """ + Verify the viewset rejects put for users that are catalog admins with an invalid + context (i.e., enterprise uuid) + """ + enterprise_catalog = EnterpriseCatalogFactory() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog.uuid}) + response = self.client.put(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_put_integrity_error_regression(self): + """ + Verify updating an enterprise catalog with a + catalog query that has a content filter identical to an existing + one causes an integrity error. + + The expected error is in serializers.py find_and_modify_catalog_query + """ + catalog_query_1 = CatalogQueryFactory( + title='catalog_query_1', + content_filter={"a": "b"}, + ) + EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + enterprise_name=self.enterprise_name, + catalog_query=catalog_query_1, + ) + catalog_query_2 = CatalogQueryFactory( + title='catalog_query_2', + content_filter={"c": "d"}, + ) + enterprise_catalog_2 = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + enterprise_name=self.enterprise_name, + catalog_query=catalog_query_2, + ) + put_data = { + 'uuid': enterprise_catalog_2.uuid, + 'title': enterprise_catalog_2.title, + 'enterprise_customer': enterprise_catalog_2.enterprise_uuid, + 'enterprise_customer_name': enterprise_catalog_2.enterprise_name, + 'enabled_course_modes': enterprise_catalog_2.enabled_course_modes, + 'publish_audit_enrollment_urls': enterprise_catalog_2.publish_audit_enrollment_urls, + 'content_filter': {"a": "b"}, + } + + url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog_2.uuid}) + response = self.client.put(url, data=put_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @ddt.data( + (False), + (True), + ) + def test_post(self, is_implicit_check): + """ + Verify the viewset handles creating an enterprise catalog + """ + if is_implicit_check: + self.remove_role_assignments() + + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.post(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self._assert_correct_new_catalog_data(self.new_catalog_uuid) + + def test_post_provisioning_admins(self): + """ + Verify the viewset handles creating an enterprise catalog + """ + self.set_up_staff_user() + self.remove_role_assignments() + self.set_up_invalid_jwt_role() + self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.post(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self._assert_correct_new_catalog_data(self.new_catalog_uuid) + + def test_post_integrity_error(self): + """ + Verify the viewset raises error when creating a duplicate enterprise catalog + """ + url = reverse('api:v1:enterprise-catalog-list') + self.client.post(url, self.new_catalog_data) + with self.assertRaises(IntegrityError): + self.client.post(url, self.new_catalog_data) + # Note: we're hitting the endpoint twice here, but this task should + # only be run once, as we should error from an integrity error the + # second time through + + def test_post_unauthorized_non_catalog_admin(self): + """ + Verify the viewset rejects post for users that are not catalog admins + """ + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.post(url, self.new_catalog_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_post_unauthorized_incorrect_jwt_context(self): + """ + Verify the viewset rejects post for users that are catalog admins with an invalid + context (i.e., enterprise uuid) + """ + catalog_data = { + 'uuid': self.new_catalog_uuid, + 'title': 'Test Title', + 'enterprise_customer': uuid.uuid4(), + 'enabled_course_modes': '["verified"]', + 'publish_audit_enrollment_urls': True, + 'content_filter': '{"content_type":"course"}', + } + self.remove_role_assignments() + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.post(url, catalog_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@ddt.ddt +class EnterpriseCatalogCRUDViewSetListTests(APITestMixin): + """ + Tests for the EnterpriseCatalogCRUDViewSet list endpoint. + """ + + def setUp(self): + super().setUp() + self.set_up_staff_user() + self.enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid) + + def test_list_for_superusers(self): + """ + Verify the viewset returns a list of all enterprise catalogs for superusers + """ + self.set_up_superuser() + url = reverse('api:v1:enterprise-catalog-list') + second_enterprise_catalog = EnterpriseCatalogFactory() + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + results = response.data['results'] + self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(uuid.UUID(results[1]['uuid']), second_enterprise_catalog.uuid) + + def test_list_for_provisioning_admins(self): + """ + Verify the viewset returns a list of all enterprise catalogs for provisioning admins + """ + self.set_up_staff_user() + self.remove_role_assignments() + self.set_up_invalid_jwt_role() + self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) + url = reverse('api:v1:enterprise-catalog-list') + second_enterprise_catalog = EnterpriseCatalogFactory() + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + results = response.data['results'] + self.assertEqual( + uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) + self.assertEqual( + uuid.UUID(results[1]['uuid']), second_enterprise_catalog.uuid) + + def test_empty_list_for_non_catalog_admin(self): + """ + Verify the viewset returns an empty list for users that are staff but not catalog admins. + """ + self.set_up_invalid_jwt_role() + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + @ddt.data( + False, + True, + ) + def test_one_catalog_for_catalog_admins(self, is_role_assigned_via_jwt): + """ + Verify the viewset returns a single catalog (when multiple exist) for catalog admins of a certain enterprise. + """ + if is_role_assigned_via_jwt: + self.assign_catalog_admin_jwt_role() + else: + self.assign_catalog_admin_feature_role() + + # create an additional catalog from a different enterprise, + # and make sure we don't see it in the response results. + EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) + + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + results = response.data['results'] + self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) + + @ddt.data( + False, + True, + ) + def test_multiple_catalogs_for_catalog_admins(self, is_role_assigned_via_jwt): + """ + Verify the viewset returns multiple catalogs for catalog admins of two different enterprises. + """ + second_enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) + + if is_role_assigned_via_jwt: + self.assign_catalog_admin_jwt_role( + self.enterprise_uuid, + second_enterprise_catalog.enterprise_uuid, + ) + else: + self.assign_catalog_admin_feature_role(enterprise_uuids=[ + self.enterprise_uuid, + second_enterprise_catalog.enterprise_uuid, + ]) + + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + results = response.data['results'] + self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(uuid.UUID(results[1]['uuid']), second_enterprise_catalog.uuid) + + @ddt.data( + False, + True, + ) + def test_every_catalog_for_catalog_admins(self, is_role_assigned_via_jwt): + """ + Verify the viewset returns catalogs of all enterprises for admins with wildcard permission. + """ + if is_role_assigned_via_jwt: + self.assign_catalog_admin_jwt_role('*') + else: + # This will cause a feature role assignment to be created with a null enterprise UUID, + # which is interpretted as having access to catalogs of ANY enterprise. + self.assign_catalog_admin_feature_role(enterprise_uuids=[None]) + + catalog_b = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) + catalog_c = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) + + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 3) + results = response.data['results'] + self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(uuid.UUID(results[1]['uuid']), catalog_b.uuid) + self.assertEqual(uuid.UUID(results[2]['uuid']), catalog_c.uuid) + + @ddt.data( + False, + True, + ) + def test_catalog_list_for_catalog_admins_with_enterprise_param(self, is_role_assigned_via_jwt): + """ + Verify the viewset returns a single catalog (when multiple exist) with GET params will provided. + """ + if is_role_assigned_via_jwt: + self.assign_catalog_admin_jwt_role() + else: + self.assign_catalog_admin_feature_role() + + # create an additional catalog from a different enterprise, + # make it so that the filter of GET params is applied and sure we don't see it in the response results. + EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) + + url = urljoin(reverse('api:v1:enterprise-catalog-list'), f'?enterprise_customer={str(self.enterprise_uuid)}') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + catalog_list = response.json()['results'] + self.assertEqual(len(catalog_list), 1) + self.assertEqual(uuid.UUID(catalog_list[0]['uuid']), self.enterprise_catalog.uuid) + + def test_list_unauthorized_catalog_learner(self): + """ + Verify the viewset rejects list for catalog learners + """ + self.set_up_catalog_learner() + url = reverse('api:v1:enterprise-catalog-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class EnterpriseCatalogCsvDataViewTests(APITestMixin): + """ + Tests for the CatalogCsvDataView view. + """ + mock_algolia_hits = {'hits': [{ + 'aggregation_key': 'course:MITx+18.01.2x', + 'key': 'MITx+18.01.2x', + 'language': 'English', + 'transcript_languages': ['English', 'Arabic'], + 'level_type': 'Intermediate', + 'content_type': 'course', + 'enterprise_catalog_query_titles': ['A la carte', 'Business', 'DemoX'], + 'partners': [ + {'name': 'Massachusetts Institute of Technology', + 'logo_image_url': 'https://edx.org/image.png'} + ], + 'programs': ['Professional Certificate'], + 'program_titles': ['Totally Awesome Program'], + 'short_description': 'description', + 'subjects': ['Math'], + 'skills': [{ + 'name': 'Probability And Statistics', + 'description': 'description' + }, { + 'name': 'Engineering Design Process', + 'description': 'description' + }], + 'title': 'Calculus 1B: Integration', + 'marketing_url': 'edx.org/foo-bar', + 'first_enrollable_paid_seat_price': 100, + 'advertised_course_run': { + 'key': 'MITx/18.01.2x/3T2015', + 'pacing_type': 'instructor_paced', + 'start': '2015-09-08T00:00:00Z', + 'end': '2015-09-08T00:00:01Z', + 'upgrade_deadline': 32503680000.0, + 'enroll_by': 32503680000.0, + 'max_effort': 10, + 'min_effort': 1, + 'weeks_to_complete': 1, + }, + 'outcome': '

learn

', + 'prerequisites_raw': '

interest

', + 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf8-catalog-query-uuids-0' + }, + { + 'aggregation_key': 'course:MITx+19', + 'key': 'MITx+19', + 'language': 'English', + 'transcript_languages': ['English', 'Arabic'], + 'level_type': 'Intermediate', + 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf9-catalog-query-uuids-0' + }, + { + 'aggregation_key': 'course:MITx+20', + 'language': 'English', + 'transcript_languages': ['English', 'Arabic'], + 'level_type': 'Intermediate', + 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf7-catalog-query-uuids-0' + } + ]} + + expected_result_data = ( + "Title,Partner Name,Start,End,Verified Upgrade Deadline,Enroll-by Date,Program Type,Program Name,Pacing," + "Level,Price,Language,Subtitles,URL,Short Description,Subjects,Key,Short Key,Skills,Min Effort," + "Max Effort,Length,What You’ll Learn,Pre-requisites,Associated Catalogs\r\nCalculus 1B: " + "Integration,Massachusetts Institute of Technology,2015-09-08,2015-09-08," + "3000-01-01,3000-01-01,Professional Certificate,Totally " + 'Awesome Program,instructor_paced,Intermediate,100,English,"English, Arabic",edx.org/foo-bar,description,' + 'Math,MITx/18.01.2x/3T2015,course:MITx+18.01.2x,"Probability And Statistics, ' + 'Engineering Design Process",1,10,1,learn,interest,"A la carte, Business"\r\n' + ) + + def setUp(self): + super().setUp() + self.set_up_staff_user() + + def _get_contains_content_base_url(self): + """ + Helper to construct the base url for the contains_content_items endpoint + """ + return reverse('api:v1:catalog-csv-data') + + def _get_mock_algolia_hits_with_missing_values(self): + mock_hits_missing_values = copy.deepcopy(self.mock_algolia_hits) + mock_hits_missing_values['hits'][0]['advertised_course_run'].pop('upgrade_deadline') + mock_hits_missing_values['hits'][0].pop('marketing_url') + mock_hits_missing_values['hits'][0].pop('first_enrollable_paid_seat_price') + mock_hits_missing_values['hits'][0]['advertised_course_run']['end'] = None + return mock_hits_missing_values + + def test_facet_validation(self): + """ + Tests that the view validates Algolia facets provided by query params + """ + url = self._get_contains_content_base_url() + invalid_facets = 'invalid_facet=wrong' + response = self.client.get(f'{url}?{invalid_facets}') + assert response.status_code == 400 + assert response.data == "Error: invalid facet(s): ['invalid_facet'] provided." + + @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_csv_data.get_initialized_algolia_client') + def test_valid_facet_validation(self, mock_algolia_client): + """ + Tests a successful request with facets. + """ + mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] + url = self._get_contains_content_base_url() + facets = 'language=English' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 200 + + expected_response = { + 'csv_data': self.expected_result_data + } + assert response.data == expected_response + + @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_csv_data.get_initialized_algolia_client') + def test_csv_row_construction_handles_missing_values(self, mock_algolia_client): + """ + Tests that the view properly handles situations where data is missing from the Algolia hit. + """ + mock_side_effects = [self._get_mock_algolia_hits_with_missing_values(), {'hits': []}] + mock_algolia_client.return_value.algolia_index.search.side_effect = mock_side_effects + url = self._get_contains_content_base_url() + facets = 'language=English' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 200 + expected_csv_data = ( + "Title,Partner Name,Start,End,Verified Upgrade Deadline,Enroll-by Date,Program Type,Program Name," + "Pacing,Level,Price,Language,Subtitles,URL,Short Description,Subjects,Key,Short Key,Skills," + "Min Effort,Max Effort,Length,What You’ll Learn,Pre-requisites,Associated Catalogs\r\n" + "Calculus 1B: Integration,Massachusetts Institute of Technology,2015-09-08," + ",,3000-01-01,Professional Certificate,Totally Awesome " + 'Program,instructor_paced,Intermediate,,English,"English, Arabic",,description,' + 'Math,MITx/18.01.2x/3T2015,course:MITx+18.01.2x,"Probability And Statistics, ' + 'Engineering Design Process",1,10,1,learn,interest,"A la carte, Business"\r\n' + ) + expected_response = { + 'csv_data': expected_csv_data + } + assert response.data == expected_response + + +class EnterpriseCatalogWorkbookViewTests(APITestMixin): + """ + Tests for the CatalogWorkbookView view. + """ + mock_algolia_hits = { + "hits": [ + { + "aggregation_key": "course:MITx+18.01.2x", + "key": "MITx+18.01.2x", + "language": "English", + "level_type": "Intermediate", + "content_type": "course", + "enterprise_catalog_query_titles": ["A la carte", "Business", "DemoX"], + "partners": [ + { + "name": "Massachusetts Institute of Technology", + "logo_image_url": "https://edx.org/image.png" + } + ], + "programs": [ + "Professional Certificate" + ], + "program_titles": [ + "Totally Awesome Program" + ], + "short_description": "description", + "subjects": [ + "Math" + ], + "skills": [ + { + "name": "Probability And Statistics", + "description": "description" + }, + { + "name": "Engineering Design Process", + "description": "description" + } + ], + "title": "Calculus 1B: Integration", + "marketing_url": "edx.org/foo-bar", + "first_enrollable_paid_seat_price": 100, + "advertised_course_run": { + "key": "MITx/18.01.2x/3T2015", + "pacing_type": "instructor_paced", + "start": "2015-09-08T00:00:00Z", + "end": "2015-09-08T00:00:01Z", + "upgrade_deadline": 32503680000.0, + "enroll_by": 32503680000.0, + "max_effort": 10, + "min_effort": 1, + "weeks_to_complete": 1 + }, + "course_runs": [ + { + "key": "MITx/18.01.2x/3T2015", + "pacing_type": "instructor_paced", + "start": "2015-09-08T00:00:00Z", + "end": "2015-09-08T00:00:01Z", + "upgrade_deadline": 32503680000.0, + "enroll_by": 32503680000.0, + "max_effort": 10, + "min_effort": 1, + "weeks_to_complete": 1 + } + ], + "outcome": "

learn

", + "prerequisites_raw": "

interest

", + "objectID": "course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf8-catalog-query-uuids-0" + }, + { + "aggregation_key": "course:OxfordX+PSF", + "content_type": "course", + "full_description": "

Duration: 6 weeks (excluding orientation)

\n

", + "key": "OxfordX+PSF", + "language": "English", + "level_type": "Introductory", + "outcome": "

On completion of this programme", + "partners": [ + { + "name": "University of Oxford", + "logo_image_url": "https://prod-discovery.edx-cdn.org/organization/logos/2b6474eb5fac.png" + } + ], + "prerequisites_raw": "", + "programs": [ + + ], + "program_titles": [ + + ], + "short_description": "

Respond to unique industry challenges", + "subjects": [ + "Business & Management" + ], + "skills": [ + { + "name": "Finance", + "description": "", + "category": { + "name": "Finance" + }, + "subcategory": { + "name": "Financial Accounting", + "category": { + "name": "Finance" + } + } + } + ], + "title": "Oxford Leading Professional Service Firms Programme", + "advertised_course_run": { + "key": "course-v1:OxfordX+PSF+2T2022", + "pacing_type": "instructor_paced", + "availability": "Current", + "start": "2022-06-15T00:00:00Z", + "end": "2022-07-24T23:59:59Z", + "min_effort": 7, + "max_effort": 10, + "weeks_to_complete": 6, + "upgrade_deadline": 32503680000.0, + "enroll_by": 32503680000.0, + }, + "course_runs": [ + + ], + "marketing_url": "https://www.edx.org/course/oxford-leadinaffiliate_partner", + "course_type": "executive-education-2u", + "entitlements": [ + { + "mode": "paid-executive-education", + "price": "2843.00", + "currency": "USD", + "sku": "67A1CAE", + "expires": "None" + } + ], + "additional_metadata": { + "external_identifier": "242576ed-7443-4c3c-a8a8-d624862a1951", + "external_url": "https://oxford-onlineprogrammes.getsmarter.com/prional-service-firms-programme/", + "lead_capture_form_url": "https://www.getsmarter.com/presentat1951", + "facts": [ + { + "heading": "Top trends", + "blurb": "

Emerging technologies are one of the top trends impacting PSFs" + } + ], + "certificate_info": { + "heading": "About the certificate", + "blurb": "

Learn how to achieve" + }, + "organic_url": "https://www.getsmarter.com/products/oxford-leacel&utm_campaign=edx_OXF-PSF", + "start_date": "2023-03-01T00:00:00Z", + "end_date": "2023-04-09T23:59:59Z", + "registration_deadline": "2023-02-21T23:59:59Z", + "variant_id": "065fcd63-55a9-43e3-b9f9-ca3ba3129ebf", + "course_term_override": "", + "product_status": "published", + "product_meta": "None" + }, + "objectID": "course-d3dc62b5-531d-40a7-b44f-1acf687b1148-catalog-query-uuids-0", + "_highlightResult": { + "additional_information": { + "value": "", + "matchLevel": "none", + "matchedWords": [ + + ] + } + }, + "skill_names": [ + { + "value": "People Management", + "matchLevel": "none", + "matchedWords": [ + + ] + } + ] + }, + { + "aggregation_key": "course:MITx+19", + "key": "MITx+19", + "language": "English", + "level_type": "Intermediate", + "objectID": "course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf9-catalog-query-uuids-0" + }, + { + "aggregation_key": "course:MITx+20", + "language": "English", + "level_type": "Intermediate", + "objectID": "course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf7-catalog-query-uuids-0" + }, + { + "aggregation_key": "course:MITx+18.01.2x", + "course_keys": ['MITx+18.01.2x'], + "content_type": "program", + "enterprise_catalog_query_titles": ["A la carte", "Business", "DemoX"], + "partners": [ + { + "name": "Harvard University", + "logo_image_url": "https://edx.org/image.png" + } + ], + "title": "Calculus 1B: Integration", + "subtitle": "this is a subtitle", + "program_type": "Professional Certificate" + } + ] + } + + def setUp(self): + super().setUp() + self.set_up_staff_user() + + def _get_contains_content_base_url(self): + """ + Helper to construct the base url for the contains_content_items endpoint + """ + return reverse('api:v1:catalog-workbook') + + @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_workbook.get_initialized_algolia_client') + def test_empty_results_error(self, mock_algolia_client): + """ + Tests when algolia returns no hits. + """ + mock_algolia_client.return_value.algolia_index.search.side_effect = [{'hits': []}] + url = self._get_contains_content_base_url() + facets = 'language=English' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 400 + + @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_workbook.get_initialized_algolia_client') + def test_success(self, mock_algolia_client): + """ + Tests basic, successful output. + """ + mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] + url = self._get_contains_content_base_url() + facets = 'language=English' + response = self.client.get(f'{url}?{facets}') + assert response.status_code == 200 + + +class EnterpriseCatalogContainsContentItemsTests(APITestMixin): + """ + Tests on the contains_content_items on enterprise catalogs endpoint + """ + + def setUp(self): + super().setUp() + # Set up catalog.has_learner_access permissions + self.set_up_catalog_learner() + self.enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid) + + def _get_contains_content_base_url(self, enterprise_catalog): + """ + Helper to construct the base url for the contains_content_items endpoint + """ + return reverse( + 'api:v1:enterprise-catalog-content-contains-content-items', + kwargs={'uuid': enterprise_catalog.uuid} + ) + + def test_contains_content_items_no_params(self): + """ + Verify the contains_content_items endpoint errors if no parameters are provided + """ + response = self.client.get(self._get_contains_content_base_url(self.enterprise_catalog)) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_contains_content_items_unauthorized_incorrect_jwt_context(self): + """ + Verify the contains_content_items endpoint rejects users with an invalid JWT context (i.e., enterprise uuid) + """ + enterprise_catalog = EnterpriseCatalogFactory() + self.remove_role_assignments() + url = self._get_contains_content_base_url(enterprise_catalog) + '?course_run_ids=fakeX' + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_contains_content_items_implicit_access(self): + """ + Verify the contains_content_items endpoint responds with 200 OK for user with implicit JWT access + """ + self.remove_role_assignments() + url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=fakeX' + self.assert_correct_contains_response(url, False) + + def test_contains_content_items_no_catalog_query(self): + """ + Verify the contains_content_items endpoint returns False if there is no associated catalog query + """ + no_catalog_query_catalog = EnterpriseCatalogFactory( + catalog_query=None, + enterprise_uuid=self.enterprise_uuid, + ) + url = self._get_contains_content_base_url(no_catalog_query_catalog) + '?program_uuids=test-uuid' + self.assert_correct_contains_response(url, False) + + def test_contains_content_items_keys_in_catalog(self): + """ + Verify the contains_content_items endpoint returns True if the keys are explicitly in the catalog + """ + content_key = 'test-key' + associated_metadata = ContentMetadataFactory(content_key=content_key) + self.add_metadata_to_catalog(self.enterprise_catalog, [associated_metadata]) + + url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=' + content_key + self.assert_correct_contains_response(url, True) + + # now query for some stuff that's *not* in the catalog + # to get a different response. + next_query_params = '?course_run_ids=' + 'test-key-foo,test-key-bar' + next_url = self._get_contains_content_base_url(self.enterprise_catalog) + next_query_params + + self.assert_correct_contains_response(next_url, False) + + # ..and finally, exercise the per-view cache on the original url. + # There should now only be queries to select the django user record, session record, and + # any available enterprise role assignments. + with self.assertNumQueries(4): + self.assert_correct_contains_response(url, True) + + def test_contains_content_items_parent_keys_in_catalog(self): + """ + Verify the contains_content_items endpoint returns True if the parent's key is in the catalog + """ + parent_metadata = ContentMetadataFactory(content_key='parent-key') + associated_metadata = ContentMetadataFactory( + content_key='child-key+101x', + parent_content_key=parent_metadata.content_key + ) + self.add_metadata_to_catalog(self.enterprise_catalog, [associated_metadata]) + + query_string = '?course_run_ids=' + parent_metadata.content_key + url = self._get_contains_content_base_url(self.enterprise_catalog) + query_string + self.assert_correct_contains_response(url, True) + + def test_contains_content_items_course_run_keys_in_catalog(self): + """ + Verify the contains_content_items endpoint returns True if a course run's key is in the catalog + """ + content_key = 'course-content-key' + course_run_content_key = 'course-run-content-key' + associated_course_metadata = ContentMetadataFactory( + content_key=content_key, + content_type=COURSE, + json_metadata={ + 'key': content_key, + 'course_runs': [{'key': course_run_content_key}], + } + ) + # create content metadata for course run associated with above course + ContentMetadataFactory(content_key=course_run_content_key, parent_content_key=content_key) + self.add_metadata_to_catalog(self.enterprise_catalog, [associated_course_metadata]) + + url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=' + course_run_content_key + self.assert_correct_contains_response(url, True) + + def test_contains_content_items_keys_not_in_catalog(self): + """ + Verify the contains_content_items endpoint returns False if neither it or its parent's keys are in the catalog + """ + associated_metadata = ContentMetadataFactory(content_key='some-unrelated-key') + self.add_metadata_to_catalog(self.enterprise_catalog, [associated_metadata]) + + url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=' + 'test-key' + self.assert_correct_contains_response(url, False) + + +@ddt.ddt +class EnterpriseCatalogGetContentMetadataTests(APITestMixin): + """ + Tests on the get_content_metadata endpoint + """ + + def setUp(self): + super().setUp() + # Set up catalog.has_learner_access permissions + self.set_up_catalog_learner() + self.enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid) + self.enterprise_catalog.catalog_query.save() + + # Delete any existing ContentMetadata records. + ContentMetadata.objects.all().delete() + + def _get_content_metadata_url(self, enterprise_catalog): + """ + Helper to get the get_content_metadata endpoint url for a given catalog + """ + return reverse('api:v1:get-content-metadata', kwargs={'uuid': enterprise_catalog.uuid}) + + def _get_expected_json_metadata(self, content_metadata, is_learner_portal_enabled): # pylint: disable=too-many-statements + """ + Helper to get the expected json_metadata from the passed in content_metadata instance + """ + content_type = content_metadata.content_type + json_metadata = content_metadata.json_metadata.copy() + enrollment_url = '{}/enterprise/{}/{}/{}/enroll/?catalog={}&utm_medium=enterprise&utm_source={}' + json_metadata['parent_content_key'] = content_metadata.parent_content_key + + json_metadata['content_last_modified'] = content_metadata.modified.isoformat()[:-6] + 'Z' + if content_metadata.is_exec_ed_2u_course and is_learner_portal_enabled: + enrollment_url = '{}/{}/executive-education-2u/course/{}?{}utm_medium=enterprise&utm_source={}' + elif content_metadata.is_exec_ed_2u_course: + if sku := json_metadata.get('entitlements', [{}])[0].get('sku'): + exec_ed_enrollment_url = ( + f"{settings.ECOMMERCE_BASE_URL}/executive-education-2u/checkout" + f"?sku={sku}" + f"&utm_medium=enterprise&utm_source={slugify(self.enterprise_catalog.enterprise_name)}" + ) + enrollment_url = enterprise_proxy_login_url(self.enterprise_slug, next_url=exec_ed_enrollment_url) + elif is_learner_portal_enabled and content_type in (COURSE, COURSE_RUN): + enrollment_url = '{}/{}/course/{}?{}utm_medium=enterprise&utm_source={}' + marketing_url = '{}?utm_medium=enterprise&utm_source={}' + xapi_activity_id = '{}/xapi/activities/{}/{}' + + if json_metadata.get('uuid'): + json_metadata['uuid'] = str(json_metadata.get('uuid')) + + if json_metadata.get('marketing_url'): + json_metadata['marketing_url'] = marketing_url.format( + json_metadata['marketing_url'], + slugify(self.enterprise_catalog.enterprise_name), + ) + + if content_type in (COURSE, COURSE_RUN): + json_metadata['xapi_activity_id'] = xapi_activity_id.format( + settings.LMS_BASE_URL, + content_type, + json_metadata.get('key'), + ) + + if content_type == COURSE: + course_key = json_metadata.get('key') + course_runs = json_metadata.get('course_runs') or [] + if is_learner_portal_enabled: + course_enrollment_url = enrollment_url.format( + settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL, + self.enterprise_slug, + course_key, + '', + slugify(self.enterprise_catalog.enterprise_name), + ) + json_metadata['enrollment_url'] = course_enrollment_url + if json_metadata.get('course_type') != EXEC_ED_2U_COURSE_TYPE: + for course_run in course_runs: + course_run_key = quote_plus(course_run.get('key')) + course_run_key_param = f'course_run_key={course_run_key}&' + course_run_enrollment_url = enrollment_url.format( + settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL, + self.enterprise_slug, + course_key, + course_run_key_param, + slugify(self.enterprise_catalog.enterprise_name), + ) + course_run.update({'enrollment_url': course_run_enrollment_url}) + course_run['parent_content_key'] = course_key + else: + course_enrollment_url = enrollment_url.format( + settings.LMS_BASE_URL, + self.enterprise_catalog.enterprise_uuid, + COURSE, + course_key, + self.enterprise_catalog.uuid, + slugify(self.enterprise_catalog.enterprise_name), + ) + json_metadata['enrollment_url'] = course_enrollment_url + if json_metadata.get('course_type') != EXEC_ED_2U_COURSE_TYPE: + for course_run in course_runs: + course_run_enrollment_url = enrollment_url.format( + settings.LMS_BASE_URL, + self.enterprise_catalog.enterprise_uuid, + COURSE, + course_run.get('key'), + self.enterprise_catalog.uuid, + slugify(self.enterprise_catalog.enterprise_name), + ) + course_run.update({'enrollment_url': course_run_enrollment_url}) + course_run['parent_content_key'] = course_key + + json_metadata['course_runs'] = course_runs + json_metadata['active'] = is_any_course_run_active(course_runs) + + if content_type == COURSE_RUN: + course_key = content_metadata.parent_content_key or get_parent_content_key(json_metadata) + if is_learner_portal_enabled: + course_run_key = quote_plus(json_metadata.get('key')) + course_run_key_param = f'course_run_key={course_run_key}&' + course_run_enrollment_url = enrollment_url.format( + settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL, + self.enterprise_slug, + course_key, + course_run_key_param, + slugify(self.enterprise_catalog.enterprise_name), + ) + json_metadata['enrollment_url'] = course_run_enrollment_url + else: + course_run_enrollment_url = enrollment_url.format( + settings.LMS_BASE_URL, + self.enterprise_catalog.enterprise_uuid, + COURSE, + json_metadata.get('key'), + self.enterprise_catalog.uuid, + slugify(self.enterprise_catalog.enterprise_name), + ) + json_metadata['enrollment_url'] = course_run_enrollment_url + + if content_type == PROGRAM: + json_metadata['enrollment_url'] = None + + return json_metadata + + def test_get_content_metadata_unauthorized_invalid_permissions(self): + """ + Verify the get_content_metadata endpoint rejects users with invalid permissions + """ + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + url = self._get_content_metadata_url(self.enterprise_catalog) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_content_metadata_unauthorized_incorrect_jwt_context(self): + """ + Verify the get_content_metadata endpoint rejects catalog learners + with an incorrect JWT context (i.e., enterprise uuid) + """ + enterprise_catalog = EnterpriseCatalogFactory() + self.remove_role_assignments() + url = self._get_content_metadata_url(enterprise_catalog) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_content_metadata_implicit_access(self): + """ + Verify the get_content_metadata endpoint responds with 200 OK for + user with implicit JWT access + """ + self.remove_role_assignments() + url = self._get_content_metadata_url(self.enterprise_catalog) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_content_metadata_no_catalog_query(self): + """ + Verify the get_content_metadata endpoint returns no results if the catalog has no catalog query + """ + no_catalog_query_catalog = EnterpriseCatalogFactory( + catalog_query=None, + enterprise_uuid=self.enterprise_uuid, + ) + url = self._get_content_metadata_url(no_catalog_query_catalog) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()['results'], []) + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + def test_get_content_metadata_content_filters_course_run_key(self, mock_api_client): + """ + Test that the get_content_metadata view GET view will support a filter including + course run key(s), even when the catalog itself doesn't explictly contain course runs. + """ + mock_api_client.return_value.get_enterprise_customer.return_value = { + 'slug': self.enterprise_slug, + 'enable_learner_portal': True, + 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + } + course_metadata = ContentMetadataFactory(content_type=COURSE) + course_key = course_metadata.content_key + course_run_key = course_metadata.json_metadata['course_runs'][0]['key'] + ContentMetadataFactory( + content_type=COURSE_RUN, + content_key=course_run_key, + parent_content_key=course_key + ) + self.add_metadata_to_catalog(self.enterprise_catalog, [course_metadata]) + + url = f'{self._get_content_metadata_url(self.enterprise_catalog)}?content_keys={quote_plus(course_run_key)}' + response = self.client.get(url) + assert response.data.get('count') == 1 + result = response.data.get('results')[0] + assert get_content_key(result) == course_metadata.content_key + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + @ddt.data( + False, + True + ) + def test_get_content_metadata_content_filters(self, learner_portal_enabled, mock_api_client): + """ + Test that the get_content_metadata view GET view will filter provided content_keys (up to a limit) + """ + mock_api_client.return_value.get_enterprise_customer.return_value = { + 'slug': self.enterprise_slug, + 'enable_learner_portal': learner_portal_enabled, + 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + } + ContentMetadataFactory.reset_sequence(10) + metadata = ContentMetadataFactory.create_batch(api_settings.PAGE_SIZE) + filtered_content_keys = [] + url = self._get_content_metadata_url(self.enterprise_catalog) + for filter_content_key_index in range(int(api_settings.PAGE_SIZE / 2)): + filtered_content_keys.append(metadata[filter_content_key_index].content_key) + url += f"&content_keys={metadata[filter_content_key_index].content_key}" + + self.add_metadata_to_catalog(self.enterprise_catalog, metadata) + response = self.client.get( + url, + {'content_keys': filtered_content_keys} + ) + assert response.data.get('count') == int(api_settings.PAGE_SIZE / 2) + for result in response.data.get('results'): + assert get_content_key(result) in filtered_content_keys + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + @ddt.data( + False, + True + ) + def test_get_content_metadata(self, learner_portal_enabled, mock_api_client): + """ + Verify the get_content_metadata endpoint returns all the metadata associated with a particular catalog + """ + mock_api_client.return_value.get_enterprise_customer.return_value = { + 'slug': self.enterprise_slug, + 'enable_learner_portal': learner_portal_enabled, + 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + } + # Create enough metadata to force pagination + course = ContentMetadataFactory.create(content_type=COURSE) + program = ContentMetadataFactory.create(content_type=PROGRAM) + pathway = ContentMetadataFactory.create(content_type=LEARNER_PATHWAY) + # important to actually link the course runs to the parent course + course_runs = ContentMetadataFactory.create_batch( + api_settings.PAGE_SIZE, + content_type=COURSE_RUN, + parent_content_key=course.content_key, + ) + course.json_metadata['course_runs'] = [run.json_metadata for run in course_runs] + course.save() + + metadata = course_runs + [course, program, pathway] + self.add_metadata_to_catalog(self.enterprise_catalog, metadata) + url = self._get_content_metadata_url(self.enterprise_catalog) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + self.assertEqual((response_data['count']), len(metadata)) + self.assertEqual(uuid.UUID(response_data['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(response_data['title'], self.enterprise_catalog.title) + self.assertEqual(uuid.UUID(response_data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) + + second_page_response = self.client.get(response_data['next']) + self.assertEqual(second_page_response.status_code, status.HTTP_200_OK) + second_response_data = second_page_response.json() + self.assertIsNone(second_response_data['next']) + + # Check that the union of both pages' data is equal to the whole set of metadata + expected_metadata = sorted( + [ + self._get_expected_json_metadata(item, learner_portal_enabled) + for item in metadata + ], + key=get_content_key, + ) + actual_metadata = sorted( + response_data['results'] + second_response_data['results'], + key=get_content_key, + ) + self.assertEqual( + json.dumps(actual_metadata, sort_keys=True), + json.dumps(expected_metadata, sort_keys=True), + ) + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + @ddt.data( + False, + True + ) + def test_get_content_metadata_traverse_pagination(self, learner_portal_enabled, mock_api_client): + """ + Verify the get_content_metadata endpoint returns all metadata on one page if the traverse pagination query + parameter is added. + """ + mock_api_client.return_value.get_enterprise_customer.return_value = { + 'slug': self.enterprise_slug, + 'enable_learner_portal': learner_portal_enabled, + 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + } + # Create enough metadata to force pagination + course = ContentMetadataFactory.create(content_type=COURSE) + # important to actually link the course runs to the parent course + course_runs = ContentMetadataFactory.create_batch( + api_settings.PAGE_SIZE, + content_type=COURSE_RUN, + parent_content_key=course.content_key, + ) + course.json_metadata['course_runs'] = [run.json_metadata for run in course_runs] + course.save() + + metadata = course_runs + [course] + self.add_metadata_to_catalog(self.enterprise_catalog, metadata) + url = self._get_content_metadata_url(self.enterprise_catalog) + '?traverse_pagination=1' + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.json() + self.assertEqual((response_data['count']), api_settings.PAGE_SIZE + 1) + self.assertEqual(uuid.UUID(response_data['uuid']), self.enterprise_catalog.uuid) + self.assertEqual(response_data['title'], self.enterprise_catalog.title) + self.assertEqual(uuid.UUID(response_data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) + + # Check that the page contains all the metadata + expected_metadata = sorted( + [ + self._get_expected_json_metadata(item, learner_portal_enabled) + for item in metadata + ], + key=get_content_key, + ) + actual_metadata = sorted( + response_data['results'], + key=get_content_key, + ) + self.assertEqual( + json.dumps(actual_metadata, sort_keys=True), + json.dumps(expected_metadata, sort_keys=True), + ) + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + @ddt.data( + False, + True + ) + def test_get_content_metadata_no_nested_enrollment_urls_exec_ed_2u( + self, + is_learner_portal_enabled, + mock_api_client + ): + """ + Verify the get_content_metadata endpoint returns + all the metadata associated with a particular catalog, and that + no course run enrollment_urls are included for exec-ed-2u course types. + """ + mock_api_client.return_value.get_enterprise_customer.return_value = { + 'slug': self.enterprise_slug, + 'enable_learner_portal': is_learner_portal_enabled, + 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + } + # Create enough metadata to force pagination + course = ContentMetadataFactory.create(content_type=COURSE) + # important to actually link the course runs to the parent course + course_runs = ContentMetadataFactory.create_batch( + 2, + content_type=COURSE_RUN, + parent_content_key=course.content_key, + ) + course.json_metadata['course_runs'] = [run.json_metadata for run in course_runs] + course.json_metadata['course_type'] = EXEC_ED_2U_COURSE_TYPE + course.json_metadata['entitlements'] = [ + { + 'mode': EXEC_ED_2U_ENTITLEMENT_MODE, + 'sku': '123456FW', + }, + ] + course.save() + + metadata = course_runs + [course] + self.add_metadata_to_catalog(self.enterprise_catalog, metadata) + + response = self.client.get(self._get_content_metadata_url(self.enterprise_catalog)) + + self.maxDiff = None + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Check that the union of both pages' data is equal to the whole set of metadata + response_data = response.json() + expected_metadata = sorted( + [ + self._get_expected_json_metadata(item, is_learner_portal_enabled) + for item in metadata + ], + key=get_content_key, + ) + actual_metadata = sorted(response_data['results'], key=get_content_key) + + self.assertEqual( + json.dumps(actual_metadata, sort_keys=True), + json.dumps(expected_metadata, sort_keys=True), + ) + + +class EnterpriseCatalogRefreshDataFromDiscoveryTests(APITestMixin): + """ + Tests for the update catalog metadata view + """ + + def setUp(self): + super().setUp() + self.set_up_staff() + self.catalog_query = CatalogQueryFactory() + self.enterprise_catalog = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + catalog_query=self.catalog_query, + ) + + @mock.patch('enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.chain') + @mock.patch( + 'enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.' + 'update_catalog_metadata_task' + ) + @mock.patch( + 'enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.' + 'update_full_content_metadata_task' + ) + @mock.patch( + 'enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.' + 'index_enterprise_catalog_in_algolia_task' + ) + def test_refresh_catalog( + self, + mock_index_task, + mock_update_full_metadata_task, + mock_update_metadata_task, + mock_chain, + ): + """ + Verify the refresh_metadata endpoint correctly calls the chain of updating/indexing tasks. + """ + # Mock the submitted task id for proper rendering + mock_chain().apply_async().task_id = 1 + # Reset the call count since it was called in the above mock + mock_chain.reset_mock() + + url = reverse('api:v1:update-enterprise-catalog', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Note that since we're mocking celery's chain, the return values from the previous task don't get passed to + # the next one, although we do use that functionality in the real view + mock_chain.assert_called_once_with( + mock_update_metadata_task.si(self.catalog_query.id), + mock_update_full_metadata_task.si(), + mock_index_task.si(), + ) + + def test_refresh_catalog_on_get_returns_405_not_allowed(self): + """ + Verify the refresh_metadata endpoint does not update the catalog metadata with a get request + """ + url = reverse('api:v1:update-enterprise-catalog', kwargs={'uuid': self.enterprise_catalog.uuid}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_refresh_catalog_on_invalid_uuid_returns_400_bad_request(self): + """ + Verify the refresh_metadata endpoint returns an HTTP_400_BAD_REQUEST status when passed an invalid ID + """ + random_uuid = uuid.uuid4() + url = reverse('api:v1:update-enterprise-catalog', kwargs={'uuid': random_uuid}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +@ddt.ddt +class DistinctCatalogQueriesViewTests(APITestMixin): + """ + Tests for the DistinctCatalogQueriesView. + """ + url = reverse('api:v1:distinct-catalog-queries') + + def setUp(self): + super().setUp() + self.set_up_staff() + self.catalog_query_one = CatalogQueryFactory() + self.enterprise_catalog_one = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + catalog_query=self.catalog_query_one, + ) + + @ddt.data( + False, + True, + ) + def test_catalogs_different_uuids(self, use_different_query): + """ + Tests that two catalogs with different CatalogQueries will return + 2 distinct CatalogQuery IDs and two catalogs with the same + CatalogQueries will return 1 distinct CatalogQuery ID. + """ + if use_different_query: + catalog_query_two = CatalogQueryFactory() + else: + catalog_query_two = self.catalog_query_one + enterprise_catalog_two = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + catalog_query=catalog_query_two, + ) + request_json = { + 'enterprise_catalog_uuids': [ + str(self.enterprise_catalog_one.uuid), + str(enterprise_catalog_two.uuid), + ] + } + response = self.client.post(self.url, request_json).json() + + if use_different_query: + assert response['num_distinct_query_ids'] == 2 + assert str(catalog_query_two.id) in response['catalog_uuids_by_catalog_query_id'] + else: + assert response['num_distinct_query_ids'] == 1 + assert str(self.catalog_query_one.id) in response['catalog_uuids_by_catalog_query_id'] + + +@ddt.ddt +class EnterpriseCustomerContentMetadataViewSetTests(APITestMixin): + """ + Tests for the Enterprise Customer Content Metadata related endpoints. + """ + + def setUp(self): + super().setUp() + self.customer_details_patcher = mock.patch( + 'enterprise_catalog.apps.catalog.models.EnterpriseCustomerDetails' + ) + self.mock_customer_details = self.customer_details_patcher.start() + self.NOW = localized_utcnow() + self.mock_customer_details.return_value.last_modified_date = self.NOW + + self.set_up_catalog_learner() + + self.catalog_query = CatalogQueryFactory() + self.enterprise_catalog = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + catalog_query=self.catalog_query, + ) + + self.content_key_1 = 'test-key' + self.content_key_2 = 'test-key-2' + self.uuid = uuid.uuid4() + self.uuid_2 = uuid.uuid4() + self.first_content_metadata = ContentMetadataFactory( + content_key=self.content_key_1, + content_uuid=self.uuid, + content_type=COURSE_RUN, + ) + self.add_metadata_to_catalog(self.enterprise_catalog, [self.first_content_metadata]) + self.second_content_metadata = ContentMetadataFactory( + content_key=self.content_key_2, + content_uuid=self.uuid_2, + content_type=COURSE, + ) + self.add_metadata_to_catalog(self.enterprise_catalog, [self.second_content_metadata]) + + self.url = reverse( + 'api:v1:enterprise-customer-content-metadata', + kwargs={'enterprise_uuid': self.enterprise_uuid} + ).replace('_', '-') + + self.addCleanup(self.customer_details_patcher.stop) + + @ddt.data(True, False) + def test_content_metadata_get_item_with_content_key(self, skip_customer_fetch): + """ + Test the base success case for the `content-metadata` view using a content key as an identifier + """ + self.mock_customer_details.reset_mock() + query_params = '' + if skip_customer_fetch: + query_params = '?skip_customer_fetch=1' + response = self.client.get(urljoin(self.url, f"{self.content_key_1}/") + query_params) + assert response.status_code == 200 + expected_data = ContentMetadataSerializer( + self.first_content_metadata, + context={ + 'enterprise_catalog': self.enterprise_catalog, + 'skip_customer_fetch': skip_customer_fetch, + }, + ).data + actual_data = response.json() + for payload_key in ['key', 'uuid']: + assert actual_data[payload_key] == expected_data[payload_key] + + if skip_customer_fetch: + self.assertFalse(self.mock_customer_details.called) + else: + self.assertTrue(self.mock_customer_details.called) + + def test_content_metadata_get_item_with_content_key_in_multiple_catalogs(self): + """ + Test the base success case for the `content-metadata` view using a content key as an identifier + when the customer has multiple catalogs in which to search for matching content. + """ + other_catalog = EnterpriseCatalogFactory( + enterprise_uuid=self.enterprise_uuid, + catalog_query=self.catalog_query, + ) + other_metadata = ContentMetadataFactory( + content_type=COURSE, + ) + self.add_metadata_to_catalog(other_catalog, [other_metadata]) + + response = self.client.get(urljoin(self.url, f"{self.content_key_1}/")) + + assert response.status_code == 200 + expected_data = ContentMetadataSerializer( + self.first_content_metadata, + context={'enterprise_catalog': self.enterprise_catalog}, + ).data + actual_data = response.json() + for payload_key in ['key', 'uuid']: + assert actual_data[payload_key] == expected_data[payload_key] + + def test_content_metadata_get_item_with_course_run_key(self): + """ + Test the success case for the `content-metadata` view using a course run key + as the content identifier, where the customer's catalog is only + directly associated with the course record containing that run. + """ + # First create a metadata record representing the course run, + # but _don't_ associate it directly with the customer's catalog. + # The searching/match logic will infer a corresponding course + # and match on that course, based on the course run record's parent_content_key. + course_run_content = ContentMetadataFactory( + content_key='my-awesome-course-run', + content_type=COURSE_RUN, + parent_content_key=self.second_content_metadata.content_key, + ) + other_catalog = EnterpriseCatalogFactory() + self.add_metadata_to_catalog(other_catalog, [course_run_content]) + + response = self.client.get(urljoin(self.url, f"{course_run_content.content_key}/")) + + expected_data = ContentMetadataSerializer( + self.second_content_metadata, + context={'enterprise_catalog': self.enterprise_catalog}, + ).data + assert response.status_code == 200 + actual_data = response.json() + for payload_key in ['key', 'uuid']: + assert actual_data[payload_key] == expected_data[payload_key] + + def test_content_metadata_get_item_with_uuid(self): + """ + Test the base success case for the `content-metadata` view using a UUID as an identifier + """ + response = self.client.get(urljoin(self.url, f"{str(self.uuid)}/")) + + assert response.status_code == 200 + expected_data = ContentMetadataSerializer(self.first_content_metadata).data + actual_data = response.json() + for payload_key in ['key', 'uuid']: + assert actual_data[payload_key] == expected_data[payload_key] + + def test_content_metadata_exists_outside_of_requested_catalog(self): + """ + Test that the content metadata list endpoint will only fetch content that exists under a catalog owned by the + requesting user's Enterprise Customer + """ + assert len(ContentMetadata.objects.all()) == 2 + other_content_key = "not-in-your-catalog" + other_content = ContentMetadataFactory( + content_key=other_content_key, + content_type=COURSE, + content_uuid=uuid.uuid4(), + ) + assert len(ContentMetadata.objects.all()) == 3 + + response = self.client.get(urljoin(self.url, f"{str(other_content_key)}/")) + + assert response.status_code == 404 + self.add_metadata_to_catalog(self.enterprise_catalog, [other_content]) + + response = self.client.get(urljoin(self.url, f"{str(other_content_key)}/")) + + assert response.json().get('key') == other_content_key + assert response.status_code == 200 + + def test_content_metadata_content_not_found(self): + """ + Test the 404 NOT FOUND case for the `content-metadata` view. + """ + response = self.client.get(urljoin(self.url, "somerandomkey/")) + assert response.status_code == 404 + + def test_content_metadata_create_not_implemented(self): + """ + Test that CREATE requests are not supported by the `content-metadata` view. + """ + response = self.client.post(urljoin(self.url, f"{self.content_key_1}/")) + assert response.status_code == 405 + + def test_content_metadata_delete_not_implemented(self): + """ + Test that DELETE requests are not supported by the `content-metadata` view. + """ + response = self.client.delete(urljoin(self.url, f"{self.content_key_1}/")) + assert response.status_code == 405 + + +@ddt.ddt +class AcademiesViewSetTests(APITestMixin): + """ + Tests for the AcademyViewSet. + """ + mock_algolia_hits = {'facetHits': [ + { + 'value': 'leadership', + 'count': 4 + }, + { + 'value': 'management', + 'count': 0 + } + ]} + + def setUp(self): + super().setUp() + self.set_up_catalog_learner() + self.tag1 = TagFactory(title=self.mock_algolia_hits['facetHits'][0]['value']) + self.tag2 = TagFactory(title=self.mock_algolia_hits['facetHits'][1]['value']) + self.academy1 = AcademyFactory() + self.academy2 = AcademyFactory(tags=[self.tag1, self.tag2]) + self.enterprise_catalog_query = CatalogQueryFactory(uuid=uuid.uuid4()) + self.enterprise_catalog1 = EnterpriseCatalogFactory(catalog_query=self.enterprise_catalog_query) + self.enterprise_catalog1.academies.add(self.academy1) + self.enterprise_catalog2 = EnterpriseCatalogFactory(catalog_query=self.enterprise_catalog_query) + self.enterprise_catalog2.academies.add(self.academy2) + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + @mock.patch('enterprise_catalog.apps.api.v1.serializers.get_initialized_algolia_client') + def test_list_for_academies(self, mock_algolia_client, mock_client): # pylint: disable=unused-argument + """ + Verify the viewset returns enterprise specific academies + """ + mock_algolia_client.return_value.algolia_index.search_for_facet_values.side_effect = [ + self.mock_algolia_hits, {'facetHits': []} + ] + params = { + 'enterprise_customer': str(self.enterprise_catalog2.enterprise_customer.uuid) + } + url = reverse('api:v1:academies-list') + '?{}'.format(urlencode(params)) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + results = response.data['results'] + self.assertEqual(uuid.UUID(results[0]['uuid']), self.academy2.uuid) + + @mock.patch('enterprise_catalog.apps.api.v1.serializers.get_initialized_algolia_client') + def test_retrieve_for_academies(self, mock_algolia_client): + """ + Verify the viewset retrieves an academy + """ + mock_algolia_client.return_value.algolia_index.search_for_facet_values.side_effect = [ + self.mock_algolia_hits, {'facetHits': []} + ] + url = reverse('api:v1:academies-detail', kwargs={ + 'uuid': self.academy2.uuid, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(uuid.UUID(response.data['uuid']), self.academy2.uuid) + + @mock.patch('enterprise_catalog.apps.api.v1.serializers.get_initialized_algolia_client') + def test_retrieve_for_tags_no_hits(self, mock_algolia_client): + """ + Verify the viewset retrieves tags of an academy only if algolia hits for tag are > 0 + """ + mock_algolia_client.return_value.algolia_index.search_for_facet_values.side_effect = [ + self.mock_algolia_hits, {'facetHits': []} + ] + url = reverse('api:v1:academies-detail', kwargs={ + 'uuid': self.academy2.uuid, + }) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(uuid.UUID(response.data['uuid']), self.academy2.uuid) + self.assertEqual(len(response.data['tags']), 1) + self.assertEqual( + response.data['tags'][0].get('title'), + self.mock_algolia_hits['facetHits'][0]['value'] + ) + + def test_list_with_missing_enterprise_customer(self): + """ + Verify the viewset returns no records when enterprise customer is missing in params + """ + url = reverse('api:v1:academies-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 0) + + +@ddt.ddt +class ContentMetadataViewTests(APITestMixin): + """ + Tests for the readonly ContentMetadata viewset. + """ + def setUp(self): + super().setUp() + self.set_up_staff() + self.content_metadata_object = ContentMetadataFactory( + content_type='course', + content_uuid=uuid.uuid4(), + ) + + def test_list_success(self): + """ + Test a successful, expected api response for the metadata list endpoint + """ + url = reverse('api:v1:content-metadata-list') + response = self.client.get(url) + response_json = response.json() + assert len(response_json.get('results')) == 1 + assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key + + def test_list_with_content_keys(self): + """ + Test a successful, expected api response for the metadata list endpoint with a supplied content keys query + param + """ + ContentMetadataFactory(content_type='course') + junk_identifier = urlencode({'content_identifiers': 'edx+101'}) + encoded_key = urlencode({'content_identifiers': self.content_metadata_object.content_key}) + query_param_string = f"?{encoded_key}&{junk_identifier}" + url = reverse('api:v1:content-metadata-list') + query_param_string + response = self.client.get(url) + response_json = response.json() + assert len(response_json.get('results')) == 1 + assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key + assert response_json.get('results')[0].get("course_runs")[0].get('start') == '2024-02-12T11:00:00Z' + + def test_get_success(self): + """ + Test a successful, expected api response for the metadata fetch endpoint + """ + url = reverse( + 'api:v1:content-metadata-detail', + kwargs={'pk': self.content_metadata_object.id} + ) + response = self.client.get(url) + response_json = response.json() + assert response_json.get('title') == self.content_metadata_object.json_metadata.get('title') + + def test_filter_list_by_uuid(self): + """ + Test that the list content_identifiers query param accepts uuids + """ + query_param_string = f"?content_identifiers={self.content_metadata_object.content_uuid}" + url = reverse('api:v1:content-metadata-list') + query_param_string + response = self.client.get(url) + response_json = response.json() + assert len(response_json.get('results')) == 1 + assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key + assert response_json.get('results')[0].get("course_runs")[0].get('start') == '2024-02-12T11:00:00Z' + + +@ddt.ddt +class CatalogQueryViewTests(APITestMixin): + """ + Tests for the readonly ContentMetadata viewset. + """ + def setUp(self): + super().setUp() + self.set_up_catalog_learner() + self.catalog_query_object = CatalogQueryFactory() + self.catalog_object = EnterpriseCatalogFactory(catalog_query=self.catalog_query_object) + self.assign_catalog_admin_jwt_role(str(self.catalog_object.enterprise_uuid)) + # Factory doesn't set up a hash, so do it manually + self.catalog_query_object.content_filter_hash = get_content_filter_hash( + self.catalog_query_object.content_filter + ) + self.catalog_query_object.save() + + def test_get_query_by_hash(self): + """ + Test that the list content_identifiers query param accepts uuids + """ + query_param_string = f"?hash={self.catalog_query_object.content_filter_hash}" + url = reverse('api:v1:get-query-by-hash') + query_param_string + response = self.client.get(url) + response_json = response.json() + # The user is a part of the enterprise that has a catalog that contains this query + # so they can view the data + assert response_json.get('uuid') == str(self.catalog_query_object.uuid) + assert str(response_json.get('content_filter')) == str(self.catalog_query_object.content_filter) + + # Permissions verification while looking up by hash + different_catalog = EnterpriseCatalogFactory() + # Factory doesn't set up a hash, so do it manually + different_catalog.catalog_query.content_filter_hash = get_content_filter_hash( + different_catalog.catalog_query.content_filter + ) + different_catalog.save() + query_param_string = f"?hash={different_catalog.catalog_query.content_filter_hash}" + url = reverse('api:v1:get-query-by-hash') + query_param_string + response = self.client.get(url) + response_json = response.json() + assert response_json == {'detail': 'Catalog query not found.'} + + # If the user is staff, they get access to everything + self.set_up_staff() + response = self.client.get(url) + response_json = response.json() + assert response_json.get('uuid') == str(different_catalog.catalog_query.uuid) + + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + response = self.client.get(url) + assert response.status_code == 404 + + self.client.logout() + response = self.client.get(url) + assert response.status_code == 401 + + def test_get_query_by_hash_not_found(self): + """ + Test that the get query by hash endpoint returns expected not found + """ + query_param_string = f"?hash={self.catalog_query_object.content_filter_hash[:-6]}aaaaaa" + url = reverse('api:v1:get-query-by-hash') + query_param_string + response = self.client.get(url) + response_json = response.json() + assert response_json == {'detail': 'Catalog query not found.'} + + def test_get_query_by_illegal_hash(self): + """ + Test that the get query by hash endpoint validates filter hashes + """ + query_param_string = "?hash=foobar" + url = reverse('api:v1:get-query-by-hash') + query_param_string + response = self.client.get(url) + response_json = response.json() + assert response_json == {'hash': ['Invalid filter hash.']} + + def test_get_query_by_hash_requires_hash(self): + """ + Test that the get query by hash requires a hash query param + """ + url = reverse('api:v1:get-query-by-hash') + response = self.client.get(url) + response_json = response.json() + assert response_json == ['You must provide at least one of the following query parameters: hash.'] + + def test_get_content_filter_hash(self): + """ + Test that get content filter hash returns md5 hash of query + """ + url = reverse('api:v1:get-content-filter-hash') + test_query = json.dumps({"content_type": ["political", "unit", "market"]}) + response = self.client.generic('GET', url, content_type='application/json', data=test_query) + assert response.json() == '35584b583415a5bd4e51cc70d898a0eb' # pylint: disable=no-member + + def test_get_content_filter_hash_bad_query(self): + """ + Test that get content filter hash returns md5 hash of query + """ + url = reverse('api:v1:get-content-filter-hash') + test_query = 'bad query' + response = self.client.generic('GET', url, content_type='application/json', data=test_query) + err_detail = "Failed to parse catalog query: JSON parse error - Expecting value: line 1 column 1 (char 0)" + assert response.json() == {"detail": err_detail} # pylint: disable=no-member + + def test_catalog_query_retrieve(self): + """ + Test that the Catalog Query viewset supports retrieving individual queries + """ + self.assign_catalog_admin_jwt_role( + self.enterprise_uuid, + self.catalog_query_object.enterprise_catalogs.first().enterprise_uuid, + ) + url = reverse('api:v1:catalog-queries-detail', kwargs={'pk': self.catalog_query_object.pk}) + response = self.client.get(url) + response_json = response.json() + assert response_json.get('uuid') == str(self.catalog_query_object.uuid) + + different_customer_catalog = EnterpriseCatalogFactory() + # We don't have a jwt token that includes an admin role for the new enterprise so it is + # essentially hidden to the requester + url = reverse('api:v1:catalog-queries-detail', kwargs={'pk': different_customer_catalog.catalog_query.pk}) + response = self.client.get(url) + assert response.status_code == 404 + + # If the user is staff, they get access to everything + self.set_up_staff() + response = self.client.get(url) + response_json = response.json() + assert response_json.get('uuid') == str(different_customer_catalog.catalog_query.uuid) + + self.client.logout() + response = self.client.get(url) + assert response.status_code == 401 + + def test_catalog_query_list(self): + """ + Test that the Catalog Query viewset supports listing queries + """ + # Create another catalog associated with another enterprise and therefore hidden to the requesting user + EnterpriseCatalogFactory() + self.assign_catalog_admin_jwt_role( + self.enterprise_uuid, + self.catalog_query_object.enterprise_catalogs.first().enterprise_uuid, + self.catalog_object.enterprise_uuid, + ) + url = reverse('api:v1:catalog-queries-list') + response = self.client.get(url) + response_json = response.json() + assert response_json.get('count') == 1 + assert response_json.get('results')[0].get('uuid') == str(self.catalog_query_object.uuid) + + # If the user is staff, they get access to everything + self.set_up_staff() + response = self.client.get(url) + response_json = response.json() + assert response_json.get('count') == 2 + + self.set_up_invalid_jwt_role() + self.remove_role_assignments() + response = self.client.get(url) + assert response.json() == { + 'count': 0, + 'current_page': 1, + 'next': None, + 'num_pages': 1, + 'previous': None, + 'results': [], + 'start': 0, + } + + self.client.logout() + response = self.client.get(url) + assert response.status_code == 401 + + +@ddt.ddt +class VideoReadOnlyViewSetTests(APITestMixin): + """ + Tests for the VideoReadOnlyViewSet. + """ + def setUp(self): + super().setUp() + self.set_up_catalog_learner() + self.parent_metadata = ContentMetadataFactory(content_type=COURSE_RUN) + self.video = VideoFactory(parent_content_metadata=self.parent_metadata) + self.video_skill = VideoSkillFactory(video=self.video) + self.video_transcript_summary = VideoTranscriptSummaryFactory(video=self.video) + + def tearDown(self): + super().tearDown() + self.video_transcript_summary.delete() + self.video_skill.delete() + self.video.delete() + + def test_retrieve_for_videos(self): + """ + Verify the viewset retrieves the correct video + """ + url = reverse('api:v1:video-detail', kwargs={ + 'edx_video_id': self.video.edx_video_id, + }) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['edx_video_id'], self.video.edx_video_id) + self.assertEqual(response.data['video_usage_key'], self.video.video_usage_key) + self.assertEqual(response.data['skills'][0]['name'], self.video_skill.name) + self.assertEqual(response.data['summary_transcripts'][0], self.video_transcript_summary.summary) + parent_key = response.data['parent_content_metadata'].get('key') + self.assertEqual(parent_key, self.video.parent_content_metadata.content_key) From 7223245e45de45c48fe166c3c24085ecdfee90b0 Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 17:14:45 +0000 Subject: [PATCH 04/11] feat: updating test_views --- .../apps/api/v2/tests/test_views.py | 1644 ----------------- 1 file changed, 1644 deletions(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index f3fd76985..d7948b5cf 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -56,1156 +56,6 @@ ) -@ddt.ddt -class EnterpriseCatalogDefaultCatalogResultsTests(APITestMixin): - """ - Tests for the DefaultCatalogResultsView class - """ - mock_algolia_hits = {'hits': [{ - 'aggregation_key': 'course:MITx+18.01.2x', - 'key': 'MITx+18.01.2x', - 'language': 'English', - 'transcript_languages': ['English', 'Arabic'], - 'level_type': 'Intermediate', - 'content_type': 'course', - 'partners': [ - {'name': 'Massachusetts Institute of Technology', - 'logo_image_url': 'https://edx.org/image.png'} - ], - 'programs': ['Professional Certificate'], - 'program_titles': ['Totally Awesome Program'], - 'short_description': 'description', - 'subjects': ['Math'], - 'skills': [{ - 'name': 'Probability And Statistics', - 'description': 'description' - }, { - 'name': 'Engineering Design Process', - 'description': 'description' - }], - 'title': 'Calculus 1B: Integration', - 'marketing_url': 'edx.org/foo-bar', - 'first_enrollable_paid_seat_price': 100, - 'advertised_course_run': { - 'key': 'MITx/18.01.2x/3T2015', - 'pacing_type': 'instructor_paced', - 'start': '2015-09-08T00:00:00Z', - 'end': '2015-09-08T00:00:01Z', - 'upgrade_deadline': 32503680000.0, - }, - 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf8-catalog-query-uuids-0' - }, - { - 'aggregation_key': 'course:MITx+19', - 'key': 'MITx+19', - 'language': 'English', - 'transcript_languages': ['English', 'Arabic'], - 'level_type': 'Intermediate', - 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf9-catalog-query-uuids-0' - }, - { - 'aggregation_key': 'course:MITx+20', - 'language': 'English', - 'transcript_languages': ['English', 'Arabic'], - 'level_type': 'Intermediate', - 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf7-catalog-query-uuids-0' - } - ]} - - def setUp(self): - super().setUp() - self.set_up_staff_user() - - def _get_contains_content_base_url(self): - """ - Helper to construct the base url for the contains_content_items endpoint - """ - return reverse('api:v1:default-course-set') - - def test_facet_validation(self): - """ - Tests that the view validates Algolia facets provided by query params - """ - url = self._get_contains_content_base_url() - invalid_facets = 'invalid_facet=wrong&enterprise_catalog_query_titles=ayylmao' - response = self.client.get(f'{url}?{invalid_facets}') - assert response.status_code == 400 - assert response.json() == {'Error': "invalid facet(s): ['invalid_facet'] provided."} - - @mock.patch('enterprise_catalog.apps.api.v1.views.default_catalog_results.get_initialized_algolia_client') - def test_valid_facet_validation(self, mock_algolia_client): - """ - Tests a successful request with facets. - """ - mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] - url = self._get_contains_content_base_url() - facets = 'enterprise_catalog_query_titles=foo&content_type=course' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 200 - - @mock.patch('enterprise_catalog.apps.api.v1.views.default_catalog_results.get_initialized_algolia_client') - def test_default_catalog_results_view_works_with_one_and_many_course_types(self, mock_algolia_client): - """ - Test that the default catalog results view rejects requests where the query param course_type is not a list - """ - mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] - url = self._get_contains_content_base_url() - facets = 'enterprise_catalog_query_titles=foo&course_type=course' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 200 - - facets = 'enterprise_catalog_query_titles=foo&course_type=course&course_type=notcourse' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 200 - - -@ddt.ddt -class EnterpriseCatalogCRUDViewSetTests(APITestMixin): - """ - Tests for the EnterpriseCatalogCRUDViewSet - """ - - def setUp(self): - super().setUp() - self.set_up_staff() - self.enterprise_catalog = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - enterprise_name=self.enterprise_name, - ) - self.new_catalog_uuid = uuid.uuid4() - self.new_catalog_data = { - 'uuid': self.new_catalog_uuid, - 'title': 'Test Title', - 'enterprise_customer': self.enterprise_uuid, - 'enterprise_customer_name': self.enterprise_name, - 'enabled_course_modes': ['verified'], - 'publish_audit_enrollment_urls': True, - 'content_filter': {'content_type': 'course'}, - } - - def _assert_correct_new_catalog_data(self, catalog_uuid): - """ - Helper for verifying the data for a created/updated catalog - """ - new_enterprise_catalog = EnterpriseCatalog.objects.get(uuid=catalog_uuid) - self.assertEqual(new_enterprise_catalog.title, self.new_catalog_data['title']) - self.assertEqual(new_enterprise_catalog.enabled_course_modes, ['verified']) - self.assertEqual( - new_enterprise_catalog.publish_audit_enrollment_urls, - self.new_catalog_data['publish_audit_enrollment_urls'], - ) - self.assertEqual( - new_enterprise_catalog.catalog_query.content_filter, - OrderedDict([('content_type', 'course')]), - ) - - def test_detail_unauthorized_catalog_learner(self): - """ - Verify the viewset rejects catalog learners for the detail route - """ - self.set_up_catalog_learner() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_patch_unauthorized_catalog_learner(self): - """ - Verify the viewset rejects patch for catalog learners - """ - self.set_up_catalog_learner() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - patch_data = {'title': 'Patch title'} - response = self.client.patch(url, patch_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_put_unauthorized_catalog_learner(self): - """ - Verify the viewset rejects put for catalog learners - """ - self.set_up_catalog_learner() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.put(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_post_unauthorized_catalog_learner(self): - """ - Verify the viewset rejects post for catalog learners - """ - self.set_up_catalog_learner() - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.post(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - @ddt.data( - (False), - (True), - ) - def test_detail(self, is_implicit_check): - """ - Verify the viewset returns the details for a single enterprise catalog - """ - if is_implicit_check: - self.remove_role_assignments() - - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.data - self.assertEqual(uuid.UUID(data['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(data['title'], self.enterprise_catalog.title) - self.assertEqual(uuid.UUID(data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) - - def test_detail_provisioning_admin(self): - """ - Verify the viewset returns the details if requesting user is a PA - """ - self.set_up_staff_user() - self.remove_role_assignments() - self.set_up_invalid_jwt_role() - self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.data - self.assertEqual(uuid.UUID(data['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(data['title'], self.enterprise_catalog.title) - self.assertEqual(uuid.UUID(data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) - - def test_detail_unauthorized_non_catalog_admin(self): - """ - Verify the viewset rejects users that are not catalog admins for the detail route - """ - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_detail_unauthorized_incorrect_jwt_context(self): - """ - Verify the viewset rejects users that are catalog admins with an invalid - context (i.e., enterprise uuid) for the detail route. - """ - enterprise_catalog = EnterpriseCatalogFactory() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog.uuid}) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - @ddt.data( - (False), - (True), - ) - def test_patch(self, is_implicit_check): - """ - Verify the viewset handles patching an enterprise catalog - """ - if is_implicit_check: - self.remove_role_assignments() - - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - patch_data = {'title': 'Patch title'} - response = self.client.patch(url, patch_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - # Verify that only the data we specifically patched changed - self.assertEqual(response.data['title'], patch_data['title']) - patched_catalog = EnterpriseCatalog.objects.get(uuid=self.enterprise_catalog.uuid) - self.assertEqual(patched_catalog.catalog_query, self.enterprise_catalog.catalog_query) - self.assertEqual(patched_catalog.enterprise_uuid, self.enterprise_catalog.enterprise_uuid) - self.assertEqual(patched_catalog.enabled_course_modes, self.enterprise_catalog.enabled_course_modes) - self.assertEqual( - patched_catalog.publish_audit_enrollment_urls, - self.enterprise_catalog.publish_audit_enrollment_urls, - ) - - def test_patch_provisioning_admins(self): - """ - Verify the viewset handles patching an enterprise catalog - """ - self.remove_role_assignments() - self.set_up_invalid_jwt_role() - self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - patch_data = {'title': 'Patch title'} - response = self.client.patch(url, patch_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - # Verify that only the data we specifically patched changed - self.assertEqual(response.data['title'], patch_data['title']) - patched_catalog = EnterpriseCatalog.objects.get(uuid=self.enterprise_catalog.uuid) - self.assertEqual(patched_catalog.catalog_query, self.enterprise_catalog.catalog_query) - self.assertEqual(patched_catalog.enterprise_uuid, self.enterprise_catalog.enterprise_uuid) - self.assertEqual(patched_catalog.enabled_course_modes, self.enterprise_catalog.enabled_course_modes) - self.assertEqual( - patched_catalog.publish_audit_enrollment_urls, - self.enterprise_catalog.publish_audit_enrollment_urls, - ) - - def test_patch_unauthorized_non_catalog_admin(self): - """ - Verify the viewset rejects patch for users that are not catalog admins - """ - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - patch_data = {'title': 'Patch title'} - response = self.client.patch(url, patch_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_patch_unauthorized_incorrect_jwt_context(self): - """ - Verify the viewset rejects patch for users that are catalog admins with an invalid - context (i.e., enterprise uuid) - """ - enterprise_catalog = EnterpriseCatalogFactory() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog.uuid}) - patch_data = {'title': 'Patch title'} - response = self.client.patch(url, patch_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - @ddt.data( - (False), - (True), - ) - def test_put(self, is_implicit_check): - """ - Verify the viewset handles replacing an enterprise catalog - """ - if is_implicit_check: - self.remove_role_assignments() - - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.put(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self._assert_correct_new_catalog_data(self.enterprise_catalog.uuid) # The UUID should not have changed - - def test_put_provisioning_admins(self): - """ - Verify the viewset allows access to PAs - """ - self.set_up_staff_user() - self.remove_role_assignments() - self.set_up_invalid_jwt_role() - self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.put(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self._assert_correct_new_catalog_data(self.enterprise_catalog.uuid) # The UUID should not have changed - - def test_put_unauthorized_non_catalog_admin(self): - """ - Verify the viewset rejects put for users that are not catalog admins - """ - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.put(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_put_unauthorized_incorrect_jwt_context(self): - """ - Verify the viewset rejects put for users that are catalog admins with an invalid - context (i.e., enterprise uuid) - """ - enterprise_catalog = EnterpriseCatalogFactory() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog.uuid}) - response = self.client.put(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_put_integrity_error_regression(self): - """ - Verify updating an enterprise catalog with a - catalog query that has a content filter identical to an existing - one causes an integrity error. - - The expected error is in serializers.py find_and_modify_catalog_query - """ - catalog_query_1 = CatalogQueryFactory( - title='catalog_query_1', - content_filter={"a": "b"}, - ) - EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - enterprise_name=self.enterprise_name, - catalog_query=catalog_query_1, - ) - catalog_query_2 = CatalogQueryFactory( - title='catalog_query_2', - content_filter={"c": "d"}, - ) - enterprise_catalog_2 = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - enterprise_name=self.enterprise_name, - catalog_query=catalog_query_2, - ) - put_data = { - 'uuid': enterprise_catalog_2.uuid, - 'title': enterprise_catalog_2.title, - 'enterprise_customer': enterprise_catalog_2.enterprise_uuid, - 'enterprise_customer_name': enterprise_catalog_2.enterprise_name, - 'enabled_course_modes': enterprise_catalog_2.enabled_course_modes, - 'publish_audit_enrollment_urls': enterprise_catalog_2.publish_audit_enrollment_urls, - 'content_filter': {"a": "b"}, - } - - url = reverse('api:v1:enterprise-catalog-detail', kwargs={'uuid': enterprise_catalog_2.uuid}) - response = self.client.put(url, data=put_data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - @ddt.data( - (False), - (True), - ) - def test_post(self, is_implicit_check): - """ - Verify the viewset handles creating an enterprise catalog - """ - if is_implicit_check: - self.remove_role_assignments() - - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.post(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self._assert_correct_new_catalog_data(self.new_catalog_uuid) - - def test_post_provisioning_admins(self): - """ - Verify the viewset handles creating an enterprise catalog - """ - self.set_up_staff_user() - self.remove_role_assignments() - self.set_up_invalid_jwt_role() - self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.post(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self._assert_correct_new_catalog_data(self.new_catalog_uuid) - - def test_post_integrity_error(self): - """ - Verify the viewset raises error when creating a duplicate enterprise catalog - """ - url = reverse('api:v1:enterprise-catalog-list') - self.client.post(url, self.new_catalog_data) - with self.assertRaises(IntegrityError): - self.client.post(url, self.new_catalog_data) - # Note: we're hitting the endpoint twice here, but this task should - # only be run once, as we should error from an integrity error the - # second time through - - def test_post_unauthorized_non_catalog_admin(self): - """ - Verify the viewset rejects post for users that are not catalog admins - """ - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.post(url, self.new_catalog_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_post_unauthorized_incorrect_jwt_context(self): - """ - Verify the viewset rejects post for users that are catalog admins with an invalid - context (i.e., enterprise uuid) - """ - catalog_data = { - 'uuid': self.new_catalog_uuid, - 'title': 'Test Title', - 'enterprise_customer': uuid.uuid4(), - 'enabled_course_modes': '["verified"]', - 'publish_audit_enrollment_urls': True, - 'content_filter': '{"content_type":"course"}', - } - self.remove_role_assignments() - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.post(url, catalog_data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - -@ddt.ddt -class EnterpriseCatalogCRUDViewSetListTests(APITestMixin): - """ - Tests for the EnterpriseCatalogCRUDViewSet list endpoint. - """ - - def setUp(self): - super().setUp() - self.set_up_staff_user() - self.enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid) - - def test_list_for_superusers(self): - """ - Verify the viewset returns a list of all enterprise catalogs for superusers - """ - self.set_up_superuser() - url = reverse('api:v1:enterprise-catalog-list') - second_enterprise_catalog = EnterpriseCatalogFactory() - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 2) - results = response.data['results'] - self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(uuid.UUID(results[1]['uuid']), second_enterprise_catalog.uuid) - - def test_list_for_provisioning_admins(self): - """ - Verify the viewset returns a list of all enterprise catalogs for provisioning admins - """ - self.set_up_staff_user() - self.remove_role_assignments() - self.set_up_invalid_jwt_role() - self.set_jwt_cookie([(SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, '*')]) - url = reverse('api:v1:enterprise-catalog-list') - second_enterprise_catalog = EnterpriseCatalogFactory() - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 2) - results = response.data['results'] - self.assertEqual( - uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) - self.assertEqual( - uuid.UUID(results[1]['uuid']), second_enterprise_catalog.uuid) - - def test_empty_list_for_non_catalog_admin(self): - """ - Verify the viewset returns an empty list for users that are staff but not catalog admins. - """ - self.set_up_invalid_jwt_role() - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 0) - - @ddt.data( - False, - True, - ) - def test_one_catalog_for_catalog_admins(self, is_role_assigned_via_jwt): - """ - Verify the viewset returns a single catalog (when multiple exist) for catalog admins of a certain enterprise. - """ - if is_role_assigned_via_jwt: - self.assign_catalog_admin_jwt_role() - else: - self.assign_catalog_admin_feature_role() - - # create an additional catalog from a different enterprise, - # and make sure we don't see it in the response results. - EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) - - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 1) - results = response.data['results'] - self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) - - @ddt.data( - False, - True, - ) - def test_multiple_catalogs_for_catalog_admins(self, is_role_assigned_via_jwt): - """ - Verify the viewset returns multiple catalogs for catalog admins of two different enterprises. - """ - second_enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) - - if is_role_assigned_via_jwt: - self.assign_catalog_admin_jwt_role( - self.enterprise_uuid, - second_enterprise_catalog.enterprise_uuid, - ) - else: - self.assign_catalog_admin_feature_role(enterprise_uuids=[ - self.enterprise_uuid, - second_enterprise_catalog.enterprise_uuid, - ]) - - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 2) - results = response.data['results'] - self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(uuid.UUID(results[1]['uuid']), second_enterprise_catalog.uuid) - - @ddt.data( - False, - True, - ) - def test_every_catalog_for_catalog_admins(self, is_role_assigned_via_jwt): - """ - Verify the viewset returns catalogs of all enterprises for admins with wildcard permission. - """ - if is_role_assigned_via_jwt: - self.assign_catalog_admin_jwt_role('*') - else: - # This will cause a feature role assignment to be created with a null enterprise UUID, - # which is interpretted as having access to catalogs of ANY enterprise. - self.assign_catalog_admin_feature_role(enterprise_uuids=[None]) - - catalog_b = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) - catalog_c = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) - - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 3) - results = response.data['results'] - self.assertEqual(uuid.UUID(results[0]['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(uuid.UUID(results[1]['uuid']), catalog_b.uuid) - self.assertEqual(uuid.UUID(results[2]['uuid']), catalog_c.uuid) - - @ddt.data( - False, - True, - ) - def test_catalog_list_for_catalog_admins_with_enterprise_param(self, is_role_assigned_via_jwt): - """ - Verify the viewset returns a single catalog (when multiple exist) with GET params will provided. - """ - if is_role_assigned_via_jwt: - self.assign_catalog_admin_jwt_role() - else: - self.assign_catalog_admin_feature_role() - - # create an additional catalog from a different enterprise, - # make it so that the filter of GET params is applied and sure we don't see it in the response results. - EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4()) - - url = urljoin(reverse('api:v1:enterprise-catalog-list'), f'?enterprise_customer={str(self.enterprise_uuid)}') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - catalog_list = response.json()['results'] - self.assertEqual(len(catalog_list), 1) - self.assertEqual(uuid.UUID(catalog_list[0]['uuid']), self.enterprise_catalog.uuid) - - def test_list_unauthorized_catalog_learner(self): - """ - Verify the viewset rejects list for catalog learners - """ - self.set_up_catalog_learner() - url = reverse('api:v1:enterprise-catalog-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - -class EnterpriseCatalogCsvDataViewTests(APITestMixin): - """ - Tests for the CatalogCsvDataView view. - """ - mock_algolia_hits = {'hits': [{ - 'aggregation_key': 'course:MITx+18.01.2x', - 'key': 'MITx+18.01.2x', - 'language': 'English', - 'transcript_languages': ['English', 'Arabic'], - 'level_type': 'Intermediate', - 'content_type': 'course', - 'enterprise_catalog_query_titles': ['A la carte', 'Business', 'DemoX'], - 'partners': [ - {'name': 'Massachusetts Institute of Technology', - 'logo_image_url': 'https://edx.org/image.png'} - ], - 'programs': ['Professional Certificate'], - 'program_titles': ['Totally Awesome Program'], - 'short_description': 'description', - 'subjects': ['Math'], - 'skills': [{ - 'name': 'Probability And Statistics', - 'description': 'description' - }, { - 'name': 'Engineering Design Process', - 'description': 'description' - }], - 'title': 'Calculus 1B: Integration', - 'marketing_url': 'edx.org/foo-bar', - 'first_enrollable_paid_seat_price': 100, - 'advertised_course_run': { - 'key': 'MITx/18.01.2x/3T2015', - 'pacing_type': 'instructor_paced', - 'start': '2015-09-08T00:00:00Z', - 'end': '2015-09-08T00:00:01Z', - 'upgrade_deadline': 32503680000.0, - 'enroll_by': 32503680000.0, - 'max_effort': 10, - 'min_effort': 1, - 'weeks_to_complete': 1, - }, - 'outcome': '

learn

', - 'prerequisites_raw': '

interest

', - 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf8-catalog-query-uuids-0' - }, - { - 'aggregation_key': 'course:MITx+19', - 'key': 'MITx+19', - 'language': 'English', - 'transcript_languages': ['English', 'Arabic'], - 'level_type': 'Intermediate', - 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf9-catalog-query-uuids-0' - }, - { - 'aggregation_key': 'course:MITx+20', - 'language': 'English', - 'transcript_languages': ['English', 'Arabic'], - 'level_type': 'Intermediate', - 'objectID': 'course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf7-catalog-query-uuids-0' - } - ]} - - expected_result_data = ( - "Title,Partner Name,Start,End,Verified Upgrade Deadline,Enroll-by Date,Program Type,Program Name,Pacing," - "Level,Price,Language,Subtitles,URL,Short Description,Subjects,Key,Short Key,Skills,Min Effort," - "Max Effort,Length,What You’ll Learn,Pre-requisites,Associated Catalogs\r\nCalculus 1B: " - "Integration,Massachusetts Institute of Technology,2015-09-08,2015-09-08," - "3000-01-01,3000-01-01,Professional Certificate,Totally " - 'Awesome Program,instructor_paced,Intermediate,100,English,"English, Arabic",edx.org/foo-bar,description,' - 'Math,MITx/18.01.2x/3T2015,course:MITx+18.01.2x,"Probability And Statistics, ' - 'Engineering Design Process",1,10,1,learn,interest,"A la carte, Business"\r\n' - ) - - def setUp(self): - super().setUp() - self.set_up_staff_user() - - def _get_contains_content_base_url(self): - """ - Helper to construct the base url for the contains_content_items endpoint - """ - return reverse('api:v1:catalog-csv-data') - - def _get_mock_algolia_hits_with_missing_values(self): - mock_hits_missing_values = copy.deepcopy(self.mock_algolia_hits) - mock_hits_missing_values['hits'][0]['advertised_course_run'].pop('upgrade_deadline') - mock_hits_missing_values['hits'][0].pop('marketing_url') - mock_hits_missing_values['hits'][0].pop('first_enrollable_paid_seat_price') - mock_hits_missing_values['hits'][0]['advertised_course_run']['end'] = None - return mock_hits_missing_values - - def test_facet_validation(self): - """ - Tests that the view validates Algolia facets provided by query params - """ - url = self._get_contains_content_base_url() - invalid_facets = 'invalid_facet=wrong' - response = self.client.get(f'{url}?{invalid_facets}') - assert response.status_code == 400 - assert response.data == "Error: invalid facet(s): ['invalid_facet'] provided." - - @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_csv_data.get_initialized_algolia_client') - def test_valid_facet_validation(self, mock_algolia_client): - """ - Tests a successful request with facets. - """ - mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] - url = self._get_contains_content_base_url() - facets = 'language=English' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 200 - - expected_response = { - 'csv_data': self.expected_result_data - } - assert response.data == expected_response - - @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_csv_data.get_initialized_algolia_client') - def test_csv_row_construction_handles_missing_values(self, mock_algolia_client): - """ - Tests that the view properly handles situations where data is missing from the Algolia hit. - """ - mock_side_effects = [self._get_mock_algolia_hits_with_missing_values(), {'hits': []}] - mock_algolia_client.return_value.algolia_index.search.side_effect = mock_side_effects - url = self._get_contains_content_base_url() - facets = 'language=English' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 200 - expected_csv_data = ( - "Title,Partner Name,Start,End,Verified Upgrade Deadline,Enroll-by Date,Program Type,Program Name," - "Pacing,Level,Price,Language,Subtitles,URL,Short Description,Subjects,Key,Short Key,Skills," - "Min Effort,Max Effort,Length,What You’ll Learn,Pre-requisites,Associated Catalogs\r\n" - "Calculus 1B: Integration,Massachusetts Institute of Technology,2015-09-08," - ",,3000-01-01,Professional Certificate,Totally Awesome " - 'Program,instructor_paced,Intermediate,,English,"English, Arabic",,description,' - 'Math,MITx/18.01.2x/3T2015,course:MITx+18.01.2x,"Probability And Statistics, ' - 'Engineering Design Process",1,10,1,learn,interest,"A la carte, Business"\r\n' - ) - expected_response = { - 'csv_data': expected_csv_data - } - assert response.data == expected_response - - -class EnterpriseCatalogWorkbookViewTests(APITestMixin): - """ - Tests for the CatalogWorkbookView view. - """ - mock_algolia_hits = { - "hits": [ - { - "aggregation_key": "course:MITx+18.01.2x", - "key": "MITx+18.01.2x", - "language": "English", - "level_type": "Intermediate", - "content_type": "course", - "enterprise_catalog_query_titles": ["A la carte", "Business", "DemoX"], - "partners": [ - { - "name": "Massachusetts Institute of Technology", - "logo_image_url": "https://edx.org/image.png" - } - ], - "programs": [ - "Professional Certificate" - ], - "program_titles": [ - "Totally Awesome Program" - ], - "short_description": "description", - "subjects": [ - "Math" - ], - "skills": [ - { - "name": "Probability And Statistics", - "description": "description" - }, - { - "name": "Engineering Design Process", - "description": "description" - } - ], - "title": "Calculus 1B: Integration", - "marketing_url": "edx.org/foo-bar", - "first_enrollable_paid_seat_price": 100, - "advertised_course_run": { - "key": "MITx/18.01.2x/3T2015", - "pacing_type": "instructor_paced", - "start": "2015-09-08T00:00:00Z", - "end": "2015-09-08T00:00:01Z", - "upgrade_deadline": 32503680000.0, - "enroll_by": 32503680000.0, - "max_effort": 10, - "min_effort": 1, - "weeks_to_complete": 1 - }, - "course_runs": [ - { - "key": "MITx/18.01.2x/3T2015", - "pacing_type": "instructor_paced", - "start": "2015-09-08T00:00:00Z", - "end": "2015-09-08T00:00:01Z", - "upgrade_deadline": 32503680000.0, - "enroll_by": 32503680000.0, - "max_effort": 10, - "min_effort": 1, - "weeks_to_complete": 1 - } - ], - "outcome": "

learn

", - "prerequisites_raw": "

interest

", - "objectID": "course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf8-catalog-query-uuids-0" - }, - { - "aggregation_key": "course:OxfordX+PSF", - "content_type": "course", - "full_description": "

Duration: 6 weeks (excluding orientation)

\n

", - "key": "OxfordX+PSF", - "language": "English", - "level_type": "Introductory", - "outcome": "

On completion of this programme", - "partners": [ - { - "name": "University of Oxford", - "logo_image_url": "https://prod-discovery.edx-cdn.org/organization/logos/2b6474eb5fac.png" - } - ], - "prerequisites_raw": "", - "programs": [ - - ], - "program_titles": [ - - ], - "short_description": "

Respond to unique industry challenges", - "subjects": [ - "Business & Management" - ], - "skills": [ - { - "name": "Finance", - "description": "", - "category": { - "name": "Finance" - }, - "subcategory": { - "name": "Financial Accounting", - "category": { - "name": "Finance" - } - } - } - ], - "title": "Oxford Leading Professional Service Firms Programme", - "advertised_course_run": { - "key": "course-v1:OxfordX+PSF+2T2022", - "pacing_type": "instructor_paced", - "availability": "Current", - "start": "2022-06-15T00:00:00Z", - "end": "2022-07-24T23:59:59Z", - "min_effort": 7, - "max_effort": 10, - "weeks_to_complete": 6, - "upgrade_deadline": 32503680000.0, - "enroll_by": 32503680000.0, - }, - "course_runs": [ - - ], - "marketing_url": "https://www.edx.org/course/oxford-leadinaffiliate_partner", - "course_type": "executive-education-2u", - "entitlements": [ - { - "mode": "paid-executive-education", - "price": "2843.00", - "currency": "USD", - "sku": "67A1CAE", - "expires": "None" - } - ], - "additional_metadata": { - "external_identifier": "242576ed-7443-4c3c-a8a8-d624862a1951", - "external_url": "https://oxford-onlineprogrammes.getsmarter.com/prional-service-firms-programme/", - "lead_capture_form_url": "https://www.getsmarter.com/presentat1951", - "facts": [ - { - "heading": "Top trends", - "blurb": "

Emerging technologies are one of the top trends impacting PSFs" - } - ], - "certificate_info": { - "heading": "About the certificate", - "blurb": "

Learn how to achieve" - }, - "organic_url": "https://www.getsmarter.com/products/oxford-leacel&utm_campaign=edx_OXF-PSF", - "start_date": "2023-03-01T00:00:00Z", - "end_date": "2023-04-09T23:59:59Z", - "registration_deadline": "2023-02-21T23:59:59Z", - "variant_id": "065fcd63-55a9-43e3-b9f9-ca3ba3129ebf", - "course_term_override": "", - "product_status": "published", - "product_meta": "None" - }, - "objectID": "course-d3dc62b5-531d-40a7-b44f-1acf687b1148-catalog-query-uuids-0", - "_highlightResult": { - "additional_information": { - "value": "", - "matchLevel": "none", - "matchedWords": [ - - ] - } - }, - "skill_names": [ - { - "value": "People Management", - "matchLevel": "none", - "matchedWords": [ - - ] - } - ] - }, - { - "aggregation_key": "course:MITx+19", - "key": "MITx+19", - "language": "English", - "level_type": "Intermediate", - "objectID": "course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf9-catalog-query-uuids-0" - }, - { - "aggregation_key": "course:MITx+20", - "language": "English", - "level_type": "Intermediate", - "objectID": "course-3543aa4e-3c64-4d9a-a343-5d5eda1dacf7-catalog-query-uuids-0" - }, - { - "aggregation_key": "course:MITx+18.01.2x", - "course_keys": ['MITx+18.01.2x'], - "content_type": "program", - "enterprise_catalog_query_titles": ["A la carte", "Business", "DemoX"], - "partners": [ - { - "name": "Harvard University", - "logo_image_url": "https://edx.org/image.png" - } - ], - "title": "Calculus 1B: Integration", - "subtitle": "this is a subtitle", - "program_type": "Professional Certificate" - } - ] - } - - def setUp(self): - super().setUp() - self.set_up_staff_user() - - def _get_contains_content_base_url(self): - """ - Helper to construct the base url for the contains_content_items endpoint - """ - return reverse('api:v1:catalog-workbook') - - @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_workbook.get_initialized_algolia_client') - def test_empty_results_error(self, mock_algolia_client): - """ - Tests when algolia returns no hits. - """ - mock_algolia_client.return_value.algolia_index.search.side_effect = [{'hits': []}] - url = self._get_contains_content_base_url() - facets = 'language=English' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 400 - - @mock.patch('enterprise_catalog.apps.api.v1.views.catalog_workbook.get_initialized_algolia_client') - def test_success(self, mock_algolia_client): - """ - Tests basic, successful output. - """ - mock_algolia_client.return_value.algolia_index.search.side_effect = [self.mock_algolia_hits, {'hits': []}] - url = self._get_contains_content_base_url() - facets = 'language=English' - response = self.client.get(f'{url}?{facets}') - assert response.status_code == 200 - - -class EnterpriseCatalogContainsContentItemsTests(APITestMixin): - """ - Tests on the contains_content_items on enterprise catalogs endpoint - """ - - def setUp(self): - super().setUp() - # Set up catalog.has_learner_access permissions - self.set_up_catalog_learner() - self.enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid) - - def _get_contains_content_base_url(self, enterprise_catalog): - """ - Helper to construct the base url for the contains_content_items endpoint - """ - return reverse( - 'api:v1:enterprise-catalog-content-contains-content-items', - kwargs={'uuid': enterprise_catalog.uuid} - ) - - def test_contains_content_items_no_params(self): - """ - Verify the contains_content_items endpoint errors if no parameters are provided - """ - response = self.client.get(self._get_contains_content_base_url(self.enterprise_catalog)) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_contains_content_items_unauthorized_incorrect_jwt_context(self): - """ - Verify the contains_content_items endpoint rejects users with an invalid JWT context (i.e., enterprise uuid) - """ - enterprise_catalog = EnterpriseCatalogFactory() - self.remove_role_assignments() - url = self._get_contains_content_base_url(enterprise_catalog) + '?course_run_ids=fakeX' - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_contains_content_items_implicit_access(self): - """ - Verify the contains_content_items endpoint responds with 200 OK for user with implicit JWT access - """ - self.remove_role_assignments() - url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=fakeX' - self.assert_correct_contains_response(url, False) - - def test_contains_content_items_no_catalog_query(self): - """ - Verify the contains_content_items endpoint returns False if there is no associated catalog query - """ - no_catalog_query_catalog = EnterpriseCatalogFactory( - catalog_query=None, - enterprise_uuid=self.enterprise_uuid, - ) - url = self._get_contains_content_base_url(no_catalog_query_catalog) + '?program_uuids=test-uuid' - self.assert_correct_contains_response(url, False) - - def test_contains_content_items_keys_in_catalog(self): - """ - Verify the contains_content_items endpoint returns True if the keys are explicitly in the catalog - """ - content_key = 'test-key' - associated_metadata = ContentMetadataFactory(content_key=content_key) - self.add_metadata_to_catalog(self.enterprise_catalog, [associated_metadata]) - - url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=' + content_key - self.assert_correct_contains_response(url, True) - - # now query for some stuff that's *not* in the catalog - # to get a different response. - next_query_params = '?course_run_ids=' + 'test-key-foo,test-key-bar' - next_url = self._get_contains_content_base_url(self.enterprise_catalog) + next_query_params - - self.assert_correct_contains_response(next_url, False) - - # ..and finally, exercise the per-view cache on the original url. - # There should now only be queries to select the django user record, session record, and - # any available enterprise role assignments. - with self.assertNumQueries(4): - self.assert_correct_contains_response(url, True) - - def test_contains_content_items_parent_keys_in_catalog(self): - """ - Verify the contains_content_items endpoint returns True if the parent's key is in the catalog - """ - parent_metadata = ContentMetadataFactory(content_key='parent-key') - associated_metadata = ContentMetadataFactory( - content_key='child-key+101x', - parent_content_key=parent_metadata.content_key - ) - self.add_metadata_to_catalog(self.enterprise_catalog, [associated_metadata]) - - query_string = '?course_run_ids=' + parent_metadata.content_key - url = self._get_contains_content_base_url(self.enterprise_catalog) + query_string - self.assert_correct_contains_response(url, True) - - def test_contains_content_items_course_run_keys_in_catalog(self): - """ - Verify the contains_content_items endpoint returns True if a course run's key is in the catalog - """ - content_key = 'course-content-key' - course_run_content_key = 'course-run-content-key' - associated_course_metadata = ContentMetadataFactory( - content_key=content_key, - content_type=COURSE, - json_metadata={ - 'key': content_key, - 'course_runs': [{'key': course_run_content_key}], - } - ) - # create content metadata for course run associated with above course - ContentMetadataFactory(content_key=course_run_content_key, parent_content_key=content_key) - self.add_metadata_to_catalog(self.enterprise_catalog, [associated_course_metadata]) - - url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=' + course_run_content_key - self.assert_correct_contains_response(url, True) - - def test_contains_content_items_keys_not_in_catalog(self): - """ - Verify the contains_content_items endpoint returns False if neither it or its parent's keys are in the catalog - """ - associated_metadata = ContentMetadataFactory(content_key='some-unrelated-key') - self.add_metadata_to_catalog(self.enterprise_catalog, [associated_metadata]) - - url = self._get_contains_content_base_url(self.enterprise_catalog) + '?course_run_ids=' + 'test-key' - self.assert_correct_contains_response(url, False) - - @ddt.ddt class EnterpriseCatalogGetContentMetadataTests(APITestMixin): """ @@ -1627,128 +477,6 @@ def test_get_content_metadata_no_nested_enrollment_urls_exec_ed_2u( ) -class EnterpriseCatalogRefreshDataFromDiscoveryTests(APITestMixin): - """ - Tests for the update catalog metadata view - """ - - def setUp(self): - super().setUp() - self.set_up_staff() - self.catalog_query = CatalogQueryFactory() - self.enterprise_catalog = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - catalog_query=self.catalog_query, - ) - - @mock.patch('enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.chain') - @mock.patch( - 'enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.' - 'update_catalog_metadata_task' - ) - @mock.patch( - 'enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.' - 'update_full_content_metadata_task' - ) - @mock.patch( - 'enterprise_catalog.apps.api.v1.views.enterprise_catalog_refresh_data_from_discovery.' - 'index_enterprise_catalog_in_algolia_task' - ) - def test_refresh_catalog( - self, - mock_index_task, - mock_update_full_metadata_task, - mock_update_metadata_task, - mock_chain, - ): - """ - Verify the refresh_metadata endpoint correctly calls the chain of updating/indexing tasks. - """ - # Mock the submitted task id for proper rendering - mock_chain().apply_async().task_id = 1 - # Reset the call count since it was called in the above mock - mock_chain.reset_mock() - - url = reverse('api:v1:update-enterprise-catalog', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.post(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - # Note that since we're mocking celery's chain, the return values from the previous task don't get passed to - # the next one, although we do use that functionality in the real view - mock_chain.assert_called_once_with( - mock_update_metadata_task.si(self.catalog_query.id), - mock_update_full_metadata_task.si(), - mock_index_task.si(), - ) - - def test_refresh_catalog_on_get_returns_405_not_allowed(self): - """ - Verify the refresh_metadata endpoint does not update the catalog metadata with a get request - """ - url = reverse('api:v1:update-enterprise-catalog', kwargs={'uuid': self.enterprise_catalog.uuid}) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - - def test_refresh_catalog_on_invalid_uuid_returns_400_bad_request(self): - """ - Verify the refresh_metadata endpoint returns an HTTP_400_BAD_REQUEST status when passed an invalid ID - """ - random_uuid = uuid.uuid4() - url = reverse('api:v1:update-enterprise-catalog', kwargs={'uuid': random_uuid}) - response = self.client.post(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -@ddt.ddt -class DistinctCatalogQueriesViewTests(APITestMixin): - """ - Tests for the DistinctCatalogQueriesView. - """ - url = reverse('api:v1:distinct-catalog-queries') - - def setUp(self): - super().setUp() - self.set_up_staff() - self.catalog_query_one = CatalogQueryFactory() - self.enterprise_catalog_one = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - catalog_query=self.catalog_query_one, - ) - - @ddt.data( - False, - True, - ) - def test_catalogs_different_uuids(self, use_different_query): - """ - Tests that two catalogs with different CatalogQueries will return - 2 distinct CatalogQuery IDs and two catalogs with the same - CatalogQueries will return 1 distinct CatalogQuery ID. - """ - if use_different_query: - catalog_query_two = CatalogQueryFactory() - else: - catalog_query_two = self.catalog_query_one - enterprise_catalog_two = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - catalog_query=catalog_query_two, - ) - request_json = { - 'enterprise_catalog_uuids': [ - str(self.enterprise_catalog_one.uuid), - str(enterprise_catalog_two.uuid), - ] - } - response = self.client.post(self.url, request_json).json() - - if use_different_query: - assert response['num_distinct_query_ids'] == 2 - assert str(catalog_query_two.id) in response['catalog_uuids_by_catalog_query_id'] - else: - assert response['num_distinct_query_ids'] == 1 - assert str(self.catalog_query_one.id) in response['catalog_uuids_by_catalog_query_id'] - - @ddt.ddt class EnterpriseCustomerContentMetadataViewSetTests(APITestMixin): """ @@ -1933,375 +661,3 @@ def test_content_metadata_delete_not_implemented(self): """ response = self.client.delete(urljoin(self.url, f"{self.content_key_1}/")) assert response.status_code == 405 - - -@ddt.ddt -class AcademiesViewSetTests(APITestMixin): - """ - Tests for the AcademyViewSet. - """ - mock_algolia_hits = {'facetHits': [ - { - 'value': 'leadership', - 'count': 4 - }, - { - 'value': 'management', - 'count': 0 - } - ]} - - def setUp(self): - super().setUp() - self.set_up_catalog_learner() - self.tag1 = TagFactory(title=self.mock_algolia_hits['facetHits'][0]['value']) - self.tag2 = TagFactory(title=self.mock_algolia_hits['facetHits'][1]['value']) - self.academy1 = AcademyFactory() - self.academy2 = AcademyFactory(tags=[self.tag1, self.tag2]) - self.enterprise_catalog_query = CatalogQueryFactory(uuid=uuid.uuid4()) - self.enterprise_catalog1 = EnterpriseCatalogFactory(catalog_query=self.enterprise_catalog_query) - self.enterprise_catalog1.academies.add(self.academy1) - self.enterprise_catalog2 = EnterpriseCatalogFactory(catalog_query=self.enterprise_catalog_query) - self.enterprise_catalog2.academies.add(self.academy2) - - @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') - @mock.patch('enterprise_catalog.apps.api.v1.serializers.get_initialized_algolia_client') - def test_list_for_academies(self, mock_algolia_client, mock_client): # pylint: disable=unused-argument - """ - Verify the viewset returns enterprise specific academies - """ - mock_algolia_client.return_value.algolia_index.search_for_facet_values.side_effect = [ - self.mock_algolia_hits, {'facetHits': []} - ] - params = { - 'enterprise_customer': str(self.enterprise_catalog2.enterprise_customer.uuid) - } - url = reverse('api:v1:academies-list') + '?{}'.format(urlencode(params)) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 1) - results = response.data['results'] - self.assertEqual(uuid.UUID(results[0]['uuid']), self.academy2.uuid) - - @mock.patch('enterprise_catalog.apps.api.v1.serializers.get_initialized_algolia_client') - def test_retrieve_for_academies(self, mock_algolia_client): - """ - Verify the viewset retrieves an academy - """ - mock_algolia_client.return_value.algolia_index.search_for_facet_values.side_effect = [ - self.mock_algolia_hits, {'facetHits': []} - ] - url = reverse('api:v1:academies-detail', kwargs={ - 'uuid': self.academy2.uuid, - }) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(uuid.UUID(response.data['uuid']), self.academy2.uuid) - - @mock.patch('enterprise_catalog.apps.api.v1.serializers.get_initialized_algolia_client') - def test_retrieve_for_tags_no_hits(self, mock_algolia_client): - """ - Verify the viewset retrieves tags of an academy only if algolia hits for tag are > 0 - """ - mock_algolia_client.return_value.algolia_index.search_for_facet_values.side_effect = [ - self.mock_algolia_hits, {'facetHits': []} - ] - url = reverse('api:v1:academies-detail', kwargs={ - 'uuid': self.academy2.uuid, - }) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(uuid.UUID(response.data['uuid']), self.academy2.uuid) - self.assertEqual(len(response.data['tags']), 1) - self.assertEqual( - response.data['tags'][0].get('title'), - self.mock_algolia_hits['facetHits'][0]['value'] - ) - - def test_list_with_missing_enterprise_customer(self): - """ - Verify the viewset returns no records when enterprise customer is missing in params - """ - url = reverse('api:v1:academies-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['count'], 0) - - -@ddt.ddt -class ContentMetadataViewTests(APITestMixin): - """ - Tests for the readonly ContentMetadata viewset. - """ - def setUp(self): - super().setUp() - self.set_up_staff() - self.content_metadata_object = ContentMetadataFactory( - content_type='course', - content_uuid=uuid.uuid4(), - ) - - def test_list_success(self): - """ - Test a successful, expected api response for the metadata list endpoint - """ - url = reverse('api:v1:content-metadata-list') - response = self.client.get(url) - response_json = response.json() - assert len(response_json.get('results')) == 1 - assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key - - def test_list_with_content_keys(self): - """ - Test a successful, expected api response for the metadata list endpoint with a supplied content keys query - param - """ - ContentMetadataFactory(content_type='course') - junk_identifier = urlencode({'content_identifiers': 'edx+101'}) - encoded_key = urlencode({'content_identifiers': self.content_metadata_object.content_key}) - query_param_string = f"?{encoded_key}&{junk_identifier}" - url = reverse('api:v1:content-metadata-list') + query_param_string - response = self.client.get(url) - response_json = response.json() - assert len(response_json.get('results')) == 1 - assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key - assert response_json.get('results')[0].get("course_runs")[0].get('start') == '2024-02-12T11:00:00Z' - - def test_get_success(self): - """ - Test a successful, expected api response for the metadata fetch endpoint - """ - url = reverse( - 'api:v1:content-metadata-detail', - kwargs={'pk': self.content_metadata_object.id} - ) - response = self.client.get(url) - response_json = response.json() - assert response_json.get('title') == self.content_metadata_object.json_metadata.get('title') - - def test_filter_list_by_uuid(self): - """ - Test that the list content_identifiers query param accepts uuids - """ - query_param_string = f"?content_identifiers={self.content_metadata_object.content_uuid}" - url = reverse('api:v1:content-metadata-list') + query_param_string - response = self.client.get(url) - response_json = response.json() - assert len(response_json.get('results')) == 1 - assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key - assert response_json.get('results')[0].get("course_runs")[0].get('start') == '2024-02-12T11:00:00Z' - - -@ddt.ddt -class CatalogQueryViewTests(APITestMixin): - """ - Tests for the readonly ContentMetadata viewset. - """ - def setUp(self): - super().setUp() - self.set_up_catalog_learner() - self.catalog_query_object = CatalogQueryFactory() - self.catalog_object = EnterpriseCatalogFactory(catalog_query=self.catalog_query_object) - self.assign_catalog_admin_jwt_role(str(self.catalog_object.enterprise_uuid)) - # Factory doesn't set up a hash, so do it manually - self.catalog_query_object.content_filter_hash = get_content_filter_hash( - self.catalog_query_object.content_filter - ) - self.catalog_query_object.save() - - def test_get_query_by_hash(self): - """ - Test that the list content_identifiers query param accepts uuids - """ - query_param_string = f"?hash={self.catalog_query_object.content_filter_hash}" - url = reverse('api:v1:get-query-by-hash') + query_param_string - response = self.client.get(url) - response_json = response.json() - # The user is a part of the enterprise that has a catalog that contains this query - # so they can view the data - assert response_json.get('uuid') == str(self.catalog_query_object.uuid) - assert str(response_json.get('content_filter')) == str(self.catalog_query_object.content_filter) - - # Permissions verification while looking up by hash - different_catalog = EnterpriseCatalogFactory() - # Factory doesn't set up a hash, so do it manually - different_catalog.catalog_query.content_filter_hash = get_content_filter_hash( - different_catalog.catalog_query.content_filter - ) - different_catalog.save() - query_param_string = f"?hash={different_catalog.catalog_query.content_filter_hash}" - url = reverse('api:v1:get-query-by-hash') + query_param_string - response = self.client.get(url) - response_json = response.json() - assert response_json == {'detail': 'Catalog query not found.'} - - # If the user is staff, they get access to everything - self.set_up_staff() - response = self.client.get(url) - response_json = response.json() - assert response_json.get('uuid') == str(different_catalog.catalog_query.uuid) - - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - response = self.client.get(url) - assert response.status_code == 404 - - self.client.logout() - response = self.client.get(url) - assert response.status_code == 401 - - def test_get_query_by_hash_not_found(self): - """ - Test that the get query by hash endpoint returns expected not found - """ - query_param_string = f"?hash={self.catalog_query_object.content_filter_hash[:-6]}aaaaaa" - url = reverse('api:v1:get-query-by-hash') + query_param_string - response = self.client.get(url) - response_json = response.json() - assert response_json == {'detail': 'Catalog query not found.'} - - def test_get_query_by_illegal_hash(self): - """ - Test that the get query by hash endpoint validates filter hashes - """ - query_param_string = "?hash=foobar" - url = reverse('api:v1:get-query-by-hash') + query_param_string - response = self.client.get(url) - response_json = response.json() - assert response_json == {'hash': ['Invalid filter hash.']} - - def test_get_query_by_hash_requires_hash(self): - """ - Test that the get query by hash requires a hash query param - """ - url = reverse('api:v1:get-query-by-hash') - response = self.client.get(url) - response_json = response.json() - assert response_json == ['You must provide at least one of the following query parameters: hash.'] - - def test_get_content_filter_hash(self): - """ - Test that get content filter hash returns md5 hash of query - """ - url = reverse('api:v1:get-content-filter-hash') - test_query = json.dumps({"content_type": ["political", "unit", "market"]}) - response = self.client.generic('GET', url, content_type='application/json', data=test_query) - assert response.json() == '35584b583415a5bd4e51cc70d898a0eb' # pylint: disable=no-member - - def test_get_content_filter_hash_bad_query(self): - """ - Test that get content filter hash returns md5 hash of query - """ - url = reverse('api:v1:get-content-filter-hash') - test_query = 'bad query' - response = self.client.generic('GET', url, content_type='application/json', data=test_query) - err_detail = "Failed to parse catalog query: JSON parse error - Expecting value: line 1 column 1 (char 0)" - assert response.json() == {"detail": err_detail} # pylint: disable=no-member - - def test_catalog_query_retrieve(self): - """ - Test that the Catalog Query viewset supports retrieving individual queries - """ - self.assign_catalog_admin_jwt_role( - self.enterprise_uuid, - self.catalog_query_object.enterprise_catalogs.first().enterprise_uuid, - ) - url = reverse('api:v1:catalog-queries-detail', kwargs={'pk': self.catalog_query_object.pk}) - response = self.client.get(url) - response_json = response.json() - assert response_json.get('uuid') == str(self.catalog_query_object.uuid) - - different_customer_catalog = EnterpriseCatalogFactory() - # We don't have a jwt token that includes an admin role for the new enterprise so it is - # essentially hidden to the requester - url = reverse('api:v1:catalog-queries-detail', kwargs={'pk': different_customer_catalog.catalog_query.pk}) - response = self.client.get(url) - assert response.status_code == 404 - - # If the user is staff, they get access to everything - self.set_up_staff() - response = self.client.get(url) - response_json = response.json() - assert response_json.get('uuid') == str(different_customer_catalog.catalog_query.uuid) - - self.client.logout() - response = self.client.get(url) - assert response.status_code == 401 - - def test_catalog_query_list(self): - """ - Test that the Catalog Query viewset supports listing queries - """ - # Create another catalog associated with another enterprise and therefore hidden to the requesting user - EnterpriseCatalogFactory() - self.assign_catalog_admin_jwt_role( - self.enterprise_uuid, - self.catalog_query_object.enterprise_catalogs.first().enterprise_uuid, - self.catalog_object.enterprise_uuid, - ) - url = reverse('api:v1:catalog-queries-list') - response = self.client.get(url) - response_json = response.json() - assert response_json.get('count') == 1 - assert response_json.get('results')[0].get('uuid') == str(self.catalog_query_object.uuid) - - # If the user is staff, they get access to everything - self.set_up_staff() - response = self.client.get(url) - response_json = response.json() - assert response_json.get('count') == 2 - - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - response = self.client.get(url) - assert response.json() == { - 'count': 0, - 'current_page': 1, - 'next': None, - 'num_pages': 1, - 'previous': None, - 'results': [], - 'start': 0, - } - - self.client.logout() - response = self.client.get(url) - assert response.status_code == 401 - - -@ddt.ddt -class VideoReadOnlyViewSetTests(APITestMixin): - """ - Tests for the VideoReadOnlyViewSet. - """ - def setUp(self): - super().setUp() - self.set_up_catalog_learner() - self.parent_metadata = ContentMetadataFactory(content_type=COURSE_RUN) - self.video = VideoFactory(parent_content_metadata=self.parent_metadata) - self.video_skill = VideoSkillFactory(video=self.video) - self.video_transcript_summary = VideoTranscriptSummaryFactory(video=self.video) - - def tearDown(self): - super().tearDown() - self.video_transcript_summary.delete() - self.video_skill.delete() - self.video.delete() - - def test_retrieve_for_videos(self): - """ - Verify the viewset retrieves the correct video - """ - url = reverse('api:v1:video-detail', kwargs={ - 'edx_video_id': self.video.edx_video_id, - }) - - response = self.client.get(url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['edx_video_id'], self.video.edx_video_id) - self.assertEqual(response.data['video_usage_key'], self.video.video_usage_key) - self.assertEqual(response.data['skills'][0]['name'], self.video_skill.name) - self.assertEqual(response.data['summary_transcripts'][0], self.video_transcript_summary.summary) - parent_key = response.data['parent_content_metadata'].get('key') - self.assertEqual(parent_key, self.video.parent_content_metadata.content_key) From 71b70028cf55a7334808bc0a8111f5fc865069b8 Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 17:22:20 +0000 Subject: [PATCH 05/11] feat: adding tests --- .../apps/api/v2/tests/test_views.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index d7948b5cf..3b635af53 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -76,7 +76,7 @@ def _get_content_metadata_url(self, enterprise_catalog): """ Helper to get the get_content_metadata endpoint url for a given catalog """ - return reverse('api:v1:get-content-metadata', kwargs={'uuid': enterprise_catalog.uuid}) + return reverse('api:v2:get-content-metadata-v2', kwargs={'uuid': enterprise_catalog.uuid}) def _get_expected_json_metadata(self, content_metadata, is_learner_portal_enabled): # pylint: disable=too-many-statements """ @@ -361,6 +361,23 @@ def test_get_content_metadata(self, learner_portal_enabled, mock_api_client): json.dumps(expected_metadata, sort_keys=True), ) + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') + @ddt.data( + False, + True + ) + def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_client): + """ + Verify the get_content_metadata endpoint returns all the metadata associated with a particular catalog + """ + mock_api_client.return_value.get_enterprise_customer.return_value = { + 'slug': self.enterprise_slug, + 'enable_learner_portal': learner_portal_enabled, + 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + } + + + @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') @ddt.data( False, From 011b130c9aa355edad2e23684d8bb8918561449f Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 17:26:34 +0000 Subject: [PATCH 06/11] chore: test cleanup --- .../apps/api/v2/tests/test_views.py | 463 ------------------ 1 file changed, 463 deletions(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index 3b635af53..09565f60a 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -199,167 +199,6 @@ def _get_expected_json_metadata(self, content_metadata, is_learner_portal_enable return json_metadata - def test_get_content_metadata_unauthorized_invalid_permissions(self): - """ - Verify the get_content_metadata endpoint rejects users with invalid permissions - """ - self.set_up_invalid_jwt_role() - self.remove_role_assignments() - url = self._get_content_metadata_url(self.enterprise_catalog) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_get_content_metadata_unauthorized_incorrect_jwt_context(self): - """ - Verify the get_content_metadata endpoint rejects catalog learners - with an incorrect JWT context (i.e., enterprise uuid) - """ - enterprise_catalog = EnterpriseCatalogFactory() - self.remove_role_assignments() - url = self._get_content_metadata_url(enterprise_catalog) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_get_content_metadata_implicit_access(self): - """ - Verify the get_content_metadata endpoint responds with 200 OK for - user with implicit JWT access - """ - self.remove_role_assignments() - url = self._get_content_metadata_url(self.enterprise_catalog) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_get_content_metadata_no_catalog_query(self): - """ - Verify the get_content_metadata endpoint returns no results if the catalog has no catalog query - """ - no_catalog_query_catalog = EnterpriseCatalogFactory( - catalog_query=None, - enterprise_uuid=self.enterprise_uuid, - ) - url = self._get_content_metadata_url(no_catalog_query_catalog) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()['results'], []) - - @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') - def test_get_content_metadata_content_filters_course_run_key(self, mock_api_client): - """ - Test that the get_content_metadata view GET view will support a filter including - course run key(s), even when the catalog itself doesn't explictly contain course runs. - """ - mock_api_client.return_value.get_enterprise_customer.return_value = { - 'slug': self.enterprise_slug, - 'enable_learner_portal': True, - 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), - } - course_metadata = ContentMetadataFactory(content_type=COURSE) - course_key = course_metadata.content_key - course_run_key = course_metadata.json_metadata['course_runs'][0]['key'] - ContentMetadataFactory( - content_type=COURSE_RUN, - content_key=course_run_key, - parent_content_key=course_key - ) - self.add_metadata_to_catalog(self.enterprise_catalog, [course_metadata]) - - url = f'{self._get_content_metadata_url(self.enterprise_catalog)}?content_keys={quote_plus(course_run_key)}' - response = self.client.get(url) - assert response.data.get('count') == 1 - result = response.data.get('results')[0] - assert get_content_key(result) == course_metadata.content_key - - @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') - @ddt.data( - False, - True - ) - def test_get_content_metadata_content_filters(self, learner_portal_enabled, mock_api_client): - """ - Test that the get_content_metadata view GET view will filter provided content_keys (up to a limit) - """ - mock_api_client.return_value.get_enterprise_customer.return_value = { - 'slug': self.enterprise_slug, - 'enable_learner_portal': learner_portal_enabled, - 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), - } - ContentMetadataFactory.reset_sequence(10) - metadata = ContentMetadataFactory.create_batch(api_settings.PAGE_SIZE) - filtered_content_keys = [] - url = self._get_content_metadata_url(self.enterprise_catalog) - for filter_content_key_index in range(int(api_settings.PAGE_SIZE / 2)): - filtered_content_keys.append(metadata[filter_content_key_index].content_key) - url += f"&content_keys={metadata[filter_content_key_index].content_key}" - - self.add_metadata_to_catalog(self.enterprise_catalog, metadata) - response = self.client.get( - url, - {'content_keys': filtered_content_keys} - ) - assert response.data.get('count') == int(api_settings.PAGE_SIZE / 2) - for result in response.data.get('results'): - assert get_content_key(result) in filtered_content_keys - - @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') - @ddt.data( - False, - True - ) - def test_get_content_metadata(self, learner_portal_enabled, mock_api_client): - """ - Verify the get_content_metadata endpoint returns all the metadata associated with a particular catalog - """ - mock_api_client.return_value.get_enterprise_customer.return_value = { - 'slug': self.enterprise_slug, - 'enable_learner_portal': learner_portal_enabled, - 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), - } - # Create enough metadata to force pagination - course = ContentMetadataFactory.create(content_type=COURSE) - program = ContentMetadataFactory.create(content_type=PROGRAM) - pathway = ContentMetadataFactory.create(content_type=LEARNER_PATHWAY) - # important to actually link the course runs to the parent course - course_runs = ContentMetadataFactory.create_batch( - api_settings.PAGE_SIZE, - content_type=COURSE_RUN, - parent_content_key=course.content_key, - ) - course.json_metadata['course_runs'] = [run.json_metadata for run in course_runs] - course.save() - - metadata = course_runs + [course, program, pathway] - self.add_metadata_to_catalog(self.enterprise_catalog, metadata) - url = self._get_content_metadata_url(self.enterprise_catalog) - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - response_data = response.json() - self.assertEqual((response_data['count']), len(metadata)) - self.assertEqual(uuid.UUID(response_data['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(response_data['title'], self.enterprise_catalog.title) - self.assertEqual(uuid.UUID(response_data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) - - second_page_response = self.client.get(response_data['next']) - self.assertEqual(second_page_response.status_code, status.HTTP_200_OK) - second_response_data = second_page_response.json() - self.assertIsNone(second_response_data['next']) - - # Check that the union of both pages' data is equal to the whole set of metadata - expected_metadata = sorted( - [ - self._get_expected_json_metadata(item, learner_portal_enabled) - for item in metadata - ], - key=get_content_key, - ) - actual_metadata = sorted( - response_data['results'] + second_response_data['results'], - key=get_content_key, - ) - self.assertEqual( - json.dumps(actual_metadata, sort_keys=True), - json.dumps(expected_metadata, sort_keys=True), - ) @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') @ddt.data( @@ -376,305 +215,3 @@ def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_ 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), } - - - @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') - @ddt.data( - False, - True - ) - def test_get_content_metadata_traverse_pagination(self, learner_portal_enabled, mock_api_client): - """ - Verify the get_content_metadata endpoint returns all metadata on one page if the traverse pagination query - parameter is added. - """ - mock_api_client.return_value.get_enterprise_customer.return_value = { - 'slug': self.enterprise_slug, - 'enable_learner_portal': learner_portal_enabled, - 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), - } - # Create enough metadata to force pagination - course = ContentMetadataFactory.create(content_type=COURSE) - # important to actually link the course runs to the parent course - course_runs = ContentMetadataFactory.create_batch( - api_settings.PAGE_SIZE, - content_type=COURSE_RUN, - parent_content_key=course.content_key, - ) - course.json_metadata['course_runs'] = [run.json_metadata for run in course_runs] - course.save() - - metadata = course_runs + [course] - self.add_metadata_to_catalog(self.enterprise_catalog, metadata) - url = self._get_content_metadata_url(self.enterprise_catalog) + '?traverse_pagination=1' - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - response_data = response.json() - self.assertEqual((response_data['count']), api_settings.PAGE_SIZE + 1) - self.assertEqual(uuid.UUID(response_data['uuid']), self.enterprise_catalog.uuid) - self.assertEqual(response_data['title'], self.enterprise_catalog.title) - self.assertEqual(uuid.UUID(response_data['enterprise_customer']), self.enterprise_catalog.enterprise_uuid) - - # Check that the page contains all the metadata - expected_metadata = sorted( - [ - self._get_expected_json_metadata(item, learner_portal_enabled) - for item in metadata - ], - key=get_content_key, - ) - actual_metadata = sorted( - response_data['results'], - key=get_content_key, - ) - self.assertEqual( - json.dumps(actual_metadata, sort_keys=True), - json.dumps(expected_metadata, sort_keys=True), - ) - - @mock.patch('enterprise_catalog.apps.api_client.enterprise_cache.EnterpriseApiClient') - @ddt.data( - False, - True - ) - def test_get_content_metadata_no_nested_enrollment_urls_exec_ed_2u( - self, - is_learner_portal_enabled, - mock_api_client - ): - """ - Verify the get_content_metadata endpoint returns - all the metadata associated with a particular catalog, and that - no course run enrollment_urls are included for exec-ed-2u course types. - """ - mock_api_client.return_value.get_enterprise_customer.return_value = { - 'slug': self.enterprise_slug, - 'enable_learner_portal': is_learner_portal_enabled, - 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), - } - # Create enough metadata to force pagination - course = ContentMetadataFactory.create(content_type=COURSE) - # important to actually link the course runs to the parent course - course_runs = ContentMetadataFactory.create_batch( - 2, - content_type=COURSE_RUN, - parent_content_key=course.content_key, - ) - course.json_metadata['course_runs'] = [run.json_metadata for run in course_runs] - course.json_metadata['course_type'] = EXEC_ED_2U_COURSE_TYPE - course.json_metadata['entitlements'] = [ - { - 'mode': EXEC_ED_2U_ENTITLEMENT_MODE, - 'sku': '123456FW', - }, - ] - course.save() - - metadata = course_runs + [course] - self.add_metadata_to_catalog(self.enterprise_catalog, metadata) - - response = self.client.get(self._get_content_metadata_url(self.enterprise_catalog)) - - self.maxDiff = None - self.assertEqual(response.status_code, status.HTTP_200_OK) - # Check that the union of both pages' data is equal to the whole set of metadata - response_data = response.json() - expected_metadata = sorted( - [ - self._get_expected_json_metadata(item, is_learner_portal_enabled) - for item in metadata - ], - key=get_content_key, - ) - actual_metadata = sorted(response_data['results'], key=get_content_key) - - self.assertEqual( - json.dumps(actual_metadata, sort_keys=True), - json.dumps(expected_metadata, sort_keys=True), - ) - - -@ddt.ddt -class EnterpriseCustomerContentMetadataViewSetTests(APITestMixin): - """ - Tests for the Enterprise Customer Content Metadata related endpoints. - """ - - def setUp(self): - super().setUp() - self.customer_details_patcher = mock.patch( - 'enterprise_catalog.apps.catalog.models.EnterpriseCustomerDetails' - ) - self.mock_customer_details = self.customer_details_patcher.start() - self.NOW = localized_utcnow() - self.mock_customer_details.return_value.last_modified_date = self.NOW - - self.set_up_catalog_learner() - - self.catalog_query = CatalogQueryFactory() - self.enterprise_catalog = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - catalog_query=self.catalog_query, - ) - - self.content_key_1 = 'test-key' - self.content_key_2 = 'test-key-2' - self.uuid = uuid.uuid4() - self.uuid_2 = uuid.uuid4() - self.first_content_metadata = ContentMetadataFactory( - content_key=self.content_key_1, - content_uuid=self.uuid, - content_type=COURSE_RUN, - ) - self.add_metadata_to_catalog(self.enterprise_catalog, [self.first_content_metadata]) - self.second_content_metadata = ContentMetadataFactory( - content_key=self.content_key_2, - content_uuid=self.uuid_2, - content_type=COURSE, - ) - self.add_metadata_to_catalog(self.enterprise_catalog, [self.second_content_metadata]) - - self.url = reverse( - 'api:v1:enterprise-customer-content-metadata', - kwargs={'enterprise_uuid': self.enterprise_uuid} - ).replace('_', '-') - - self.addCleanup(self.customer_details_patcher.stop) - - @ddt.data(True, False) - def test_content_metadata_get_item_with_content_key(self, skip_customer_fetch): - """ - Test the base success case for the `content-metadata` view using a content key as an identifier - """ - self.mock_customer_details.reset_mock() - query_params = '' - if skip_customer_fetch: - query_params = '?skip_customer_fetch=1' - response = self.client.get(urljoin(self.url, f"{self.content_key_1}/") + query_params) - assert response.status_code == 200 - expected_data = ContentMetadataSerializer( - self.first_content_metadata, - context={ - 'enterprise_catalog': self.enterprise_catalog, - 'skip_customer_fetch': skip_customer_fetch, - }, - ).data - actual_data = response.json() - for payload_key in ['key', 'uuid']: - assert actual_data[payload_key] == expected_data[payload_key] - - if skip_customer_fetch: - self.assertFalse(self.mock_customer_details.called) - else: - self.assertTrue(self.mock_customer_details.called) - - def test_content_metadata_get_item_with_content_key_in_multiple_catalogs(self): - """ - Test the base success case for the `content-metadata` view using a content key as an identifier - when the customer has multiple catalogs in which to search for matching content. - """ - other_catalog = EnterpriseCatalogFactory( - enterprise_uuid=self.enterprise_uuid, - catalog_query=self.catalog_query, - ) - other_metadata = ContentMetadataFactory( - content_type=COURSE, - ) - self.add_metadata_to_catalog(other_catalog, [other_metadata]) - - response = self.client.get(urljoin(self.url, f"{self.content_key_1}/")) - - assert response.status_code == 200 - expected_data = ContentMetadataSerializer( - self.first_content_metadata, - context={'enterprise_catalog': self.enterprise_catalog}, - ).data - actual_data = response.json() - for payload_key in ['key', 'uuid']: - assert actual_data[payload_key] == expected_data[payload_key] - - def test_content_metadata_get_item_with_course_run_key(self): - """ - Test the success case for the `content-metadata` view using a course run key - as the content identifier, where the customer's catalog is only - directly associated with the course record containing that run. - """ - # First create a metadata record representing the course run, - # but _don't_ associate it directly with the customer's catalog. - # The searching/match logic will infer a corresponding course - # and match on that course, based on the course run record's parent_content_key. - course_run_content = ContentMetadataFactory( - content_key='my-awesome-course-run', - content_type=COURSE_RUN, - parent_content_key=self.second_content_metadata.content_key, - ) - other_catalog = EnterpriseCatalogFactory() - self.add_metadata_to_catalog(other_catalog, [course_run_content]) - - response = self.client.get(urljoin(self.url, f"{course_run_content.content_key}/")) - - expected_data = ContentMetadataSerializer( - self.second_content_metadata, - context={'enterprise_catalog': self.enterprise_catalog}, - ).data - assert response.status_code == 200 - actual_data = response.json() - for payload_key in ['key', 'uuid']: - assert actual_data[payload_key] == expected_data[payload_key] - - def test_content_metadata_get_item_with_uuid(self): - """ - Test the base success case for the `content-metadata` view using a UUID as an identifier - """ - response = self.client.get(urljoin(self.url, f"{str(self.uuid)}/")) - - assert response.status_code == 200 - expected_data = ContentMetadataSerializer(self.first_content_metadata).data - actual_data = response.json() - for payload_key in ['key', 'uuid']: - assert actual_data[payload_key] == expected_data[payload_key] - - def test_content_metadata_exists_outside_of_requested_catalog(self): - """ - Test that the content metadata list endpoint will only fetch content that exists under a catalog owned by the - requesting user's Enterprise Customer - """ - assert len(ContentMetadata.objects.all()) == 2 - other_content_key = "not-in-your-catalog" - other_content = ContentMetadataFactory( - content_key=other_content_key, - content_type=COURSE, - content_uuid=uuid.uuid4(), - ) - assert len(ContentMetadata.objects.all()) == 3 - - response = self.client.get(urljoin(self.url, f"{str(other_content_key)}/")) - - assert response.status_code == 404 - self.add_metadata_to_catalog(self.enterprise_catalog, [other_content]) - - response = self.client.get(urljoin(self.url, f"{str(other_content_key)}/")) - - assert response.json().get('key') == other_content_key - assert response.status_code == 200 - - def test_content_metadata_content_not_found(self): - """ - Test the 404 NOT FOUND case for the `content-metadata` view. - """ - response = self.client.get(urljoin(self.url, "somerandomkey/")) - assert response.status_code == 404 - - def test_content_metadata_create_not_implemented(self): - """ - Test that CREATE requests are not supported by the `content-metadata` view. - """ - response = self.client.post(urljoin(self.url, f"{self.content_key_1}/")) - assert response.status_code == 405 - - def test_content_metadata_delete_not_implemented(self): - """ - Test that DELETE requests are not supported by the `content-metadata` view. - """ - response = self.client.delete(urljoin(self.url, f"{self.content_key_1}/")) - assert response.status_code == 405 From 6e6f92d15c365d8ff48e4f8a18a8e2b3d71924a1 Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 17:39:38 +0000 Subject: [PATCH 07/11] chore: updating test --- .../apps/api/v2/tests/test_views.py | 34 ++++++++++++++++++- ...enterprise_catalog_get_content_metadata.py | 5 ++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index 09565f60a..a892ded9b 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -34,8 +34,10 @@ SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, ) from enterprise_catalog.apps.catalog.models import ( + CatalogQuery, ContentMetadata, EnterpriseCatalog, + RestrictedCourseMetadata ) from enterprise_catalog.apps.catalog.tests.factories import ( CatalogQueryFactory, @@ -214,4 +216,34 @@ def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_ 'enable_learner_portal': learner_portal_enabled, 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), } - + + catalog = EnterpriseCatalogFactory() + catalog_query = catalog.catalog_query + course_mixed = ContentMetadataFactory(content_key='mixed_course', content_type='course') + course_mixed.catalog_queries.set(CatalogQuery.objects.all()) + course_mixed_run1 = ContentMetadataFactory(content_key='course_mixed_run1', content_type='courserun') + course_mixed_run2 = ContentMetadataFactory(content_key='course_mixed_run2', content_type='courserun', + is_restricted_run=True) + course_mixed_run2.catalog_queries.set(CatalogQuery.objects.all()) + # TODO: create restricted course for mixed course + course_unicorn = ContentMetadataFactory(content_key='unicorn_course', content_type='course') + course_unicorn.catalog_queries.set(CatalogQuery.objects.all()) + course_unicorn_run1 = ContentMetadataFactory(content_key='course_unicorn_run1', content_type='courserun', + is_restricted_run=True) + course_unicorn_run1.catalog_queries.set(CatalogQuery.objects.all()) + restricted_course_unicorn, _ = RestrictedCourseMetadata.objects.get_or_create( + content_key=course_unicorn.content_key, + content_type=course_unicorn.content_type, + ) + restricted_course_unicorn.catalog_query = CatalogQuery.objects.first() + restricted_course_unicorn.unrestricted_parent = course_unicorn + restricted_course_unicorn.save() + assert catalog.content_metadata[1].json_metadata + assert not catalog.content_metadata_with_restricted[2].json_metadata + assert catalog.get_matching_content(['mixed_course'], include_restricted=False)[0].json_metadata + assert catalog.get_matching_content(['mixed_course'], include_restricted=True)[0].json_metadata + assert catalog.get_matching_content(['unicorn_course'], include_restricted=False)[0].json_metadata + assert not catalog.get_matching_content(['unicorn_course'], include_restricted=True)[0].json_metadata + assert len(catalog.get_matching_content(['course_unicorn_run1'], include_restricted=False)) == 0 + assert len(catalog.get_matching_content(['course_unicorn_run1'], include_restricted=True)) == 1 + diff --git a/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py b/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py index dc3f3ef30..88f9f6326 100644 --- a/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py +++ b/enterprise_catalog/apps/api/v2/views/enterprise_catalog_get_content_metadata.py @@ -13,6 +13,9 @@ def get_queryset(self, **kwargs): queryset = self.enterprise_catalog.content_metadata_with_restricted content_filter = kwargs.get('content_keys_filter') if content_filter: - queryset = self.enterprise_catalog.get_matching_content(content_keys=content_filter, include_restricted=True) + queryset = self.enterprise_catalog.get_matching_content( + content_keys=content_filter, + include_restricted=True + ) return queryset.order_by('catalog_queries') From 8f83e0d7f22458a0396f9eb9f20cd12825536cab Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 17:39:46 +0000 Subject: [PATCH 08/11] chore: updating test --- enterprise_catalog/apps/api/v2/tests/test_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index a892ded9b..14b3f5bad 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -246,4 +246,3 @@ def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_ assert not catalog.get_matching_content(['unicorn_course'], include_restricted=True)[0].json_metadata assert len(catalog.get_matching_content(['course_unicorn_run1'], include_restricted=False)) == 0 assert len(catalog.get_matching_content(['course_unicorn_run1'], include_restricted=True)) == 1 - From 590b9e67d011b9966de33bf77f014c4ac6e6e69b Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 20:14:25 +0000 Subject: [PATCH 09/11] chore: updating tests --- .../apps/api/v2/tests/test_views.py | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index 14b3f5bad..2da319589 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -43,6 +43,7 @@ CatalogQueryFactory, ContentMetadataFactory, EnterpriseCatalogFactory, + RestrictedCourseMetadataFactory ) from enterprise_catalog.apps.catalog.utils import ( enterprise_proxy_login_url, @@ -216,33 +217,71 @@ def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_ 'enable_learner_portal': learner_portal_enabled, 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), } + combined_course_content_key = 'combined_course' + combined_course_run_1_content_key = 'combined_course_run_1' + combined_course_run_2_content_key = 'combined_course_run_2' + fully_restricted_course_content_key = 'fully_restricted_course' + fully_restricted_course_run_1_content_key = 'fully_restricted_course_run_1' catalog = EnterpriseCatalogFactory() - catalog_query = catalog.catalog_query - course_mixed = ContentMetadataFactory(content_key='mixed_course', content_type='course') - course_mixed.catalog_queries.set(CatalogQuery.objects.all()) - course_mixed_run1 = ContentMetadataFactory(content_key='course_mixed_run1', content_type='courserun') - course_mixed_run2 = ContentMetadataFactory(content_key='course_mixed_run2', content_type='courserun', - is_restricted_run=True) - course_mixed_run2.catalog_queries.set(CatalogQuery.objects.all()) + combined_course = ContentMetadataFactory(content_key=combined_course_content_key, content_type=COURSE) + combined_course_run_1 = ContentMetadataFactory( + content_key=combined_course_run_1_content_key, + content_type=COURSE_RUN + ) + combined_course_run_2 = RestrictedCourseMetadataFactory( + content_key=combined_course_run_2_content_key, + content_type=COURSE_RUN, + ) # TODO: create restricted course for mixed course - course_unicorn = ContentMetadataFactory(content_key='unicorn_course', content_type='course') - course_unicorn.catalog_queries.set(CatalogQuery.objects.all()) - course_unicorn_run1 = ContentMetadataFactory(content_key='course_unicorn_run1', content_type='courserun', - is_restricted_run=True) - course_unicorn_run1.catalog_queries.set(CatalogQuery.objects.all()) - restricted_course_unicorn, _ = RestrictedCourseMetadata.objects.get_or_create( - content_key=course_unicorn.content_key, - content_type=course_unicorn.content_type, + fully_restricted_course = ContentMetadataFactory( + content_key=fully_restricted_course_content_key, + content_type=COURSE + ) + fully_restricted_course_run_1 = RestrictedCourseMetadataFactory( + content_key=fully_restricted_course_run_1_content_key, + content_type=COURSE_RUN, + ) + for course_entity in [ + combined_course, + combined_course_run_2, + fully_restricted_course, + fully_restricted_course_run_1 + ]: + course_entity.catalog_queries.set(CatalogQuery.objects.all()) + + restricted_course_metadata, _ = RestrictedCourseMetadata.objects.get_or_create( + content_key=fully_restricted_course.content_key, + content_type=fully_restricted_course.content_type, + ) + restricted_course_metadata.catalog_query = CatalogQuery.objects.first() + restricted_course_metadata.unrestricted_parent = fully_restricted_course + restricted_course_metadata.save() + + self.assertIsNotNone(catalog.content_metadata[1].json_metadata) + self.assertIsNone(catalog.content_metadata_with_restricted[2].json_metadata) + self.assertIsNotNone(catalog.get_matching_content( + [combined_course_content_key], + include_restricted=False + )[0].json_metadata) + self.assertIsNotNone(catalog.get_matching_content( + [combined_course_content_key], + include_restricted=True + )[0].json_metadata) + self.assertIsNotNone(catalog.get_matching_content( + [fully_restricted_course_content_key], + include_restricted=False + )[0].json_metadata) + self.assertIsNone(catalog.get_matching_content( + [fully_restricted_course_content_key], + include_restricted=True + )[0].json_metadata) + + self.assertEqual( + len(catalog.get_matching_content([fully_restricted_course_run_1_content_key], include_restricted=False)), + 0 + ) + self.assertEqual( + len(catalog.get_matching_content([fully_restricted_course_run_1_content_key], include_restricted=True)), + 1 ) - restricted_course_unicorn.catalog_query = CatalogQuery.objects.first() - restricted_course_unicorn.unrestricted_parent = course_unicorn - restricted_course_unicorn.save() - assert catalog.content_metadata[1].json_metadata - assert not catalog.content_metadata_with_restricted[2].json_metadata - assert catalog.get_matching_content(['mixed_course'], include_restricted=False)[0].json_metadata - assert catalog.get_matching_content(['mixed_course'], include_restricted=True)[0].json_metadata - assert catalog.get_matching_content(['unicorn_course'], include_restricted=False)[0].json_metadata - assert not catalog.get_matching_content(['unicorn_course'], include_restricted=True)[0].json_metadata - assert len(catalog.get_matching_content(['course_unicorn_run1'], include_restricted=False)) == 0 - assert len(catalog.get_matching_content(['course_unicorn_run1'], include_restricted=True)) == 1 From 2e7977aeb91b490fd7e9d319a899d04f22ceeaac Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Wed, 9 Oct 2024 21:31:15 +0000 Subject: [PATCH 10/11] chore: update tests --- .../apps/api/v2/tests/test_views.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index 2da319589..b8b5bae1b 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -37,13 +37,14 @@ CatalogQuery, ContentMetadata, EnterpriseCatalog, - RestrictedCourseMetadata + RestrictedCourseMetadata, ) from enterprise_catalog.apps.catalog.tests.factories import ( CatalogQueryFactory, ContentMetadataFactory, EnterpriseCatalogFactory, - RestrictedCourseMetadataFactory + RestrictedCourseMetadataFactory, + RestrictedRunAllowedForRestrictedCourseFactory ) from enterprise_catalog.apps.catalog.utils import ( enterprise_proxy_login_url, @@ -234,13 +235,9 @@ def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_ content_type=COURSE_RUN, ) # TODO: create restricted course for mixed course - fully_restricted_course = ContentMetadataFactory( - content_key=fully_restricted_course_content_key, - content_type=COURSE - ) - fully_restricted_course_run_1 = RestrictedCourseMetadataFactory( - content_key=fully_restricted_course_run_1_content_key, - content_type=COURSE_RUN, + fully_restricted_course = RestrictedCourseMetadataFactory(content_key=fully_restricted_course_content_key) + fully_restricted_course_run_1 = RestrictedRunAllowedForRestrictedCourseFactory( + content_key=fully_restricted_course_run_1_content_key ) for course_entity in [ combined_course, From ac575ed04d98b44c97d565f18d089dea3326d04d Mon Sep 17 00:00:00 2001 From: Prashant Makwana Date: Thu, 10 Oct 2024 15:05:16 +0000 Subject: [PATCH 11/11] chore: updating test --- .../apps/api/v2/tests/test_views.py | 177 ++++++++++++++---- 1 file changed, 139 insertions(+), 38 deletions(-) diff --git a/enterprise_catalog/apps/api/v2/tests/test_views.py b/enterprise_catalog/apps/api/v2/tests/test_views.py index b8b5bae1b..3c6e88ee5 100644 --- a/enterprise_catalog/apps/api/v2/tests/test_views.py +++ b/enterprise_catalog/apps/api/v2/tests/test_views.py @@ -210,75 +210,176 @@ def _get_expected_json_metadata(self, content_metadata, is_learner_portal_enable True ) def test_get_content_metadata_restricted(self, learner_portal_enabled, mock_api_client): + # """ + # Verify the get_content_metadata endpoint returns all the metadata associated with a particular catalog + # """ + # mock_api_client.return_value.get_enterprise_customer.return_value = { + # 'slug': self.enterprise_slug, + # 'enable_learner_portal': learner_portal_enabled, + # 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), + # } + # combined_course_content_key = 'combined_course' + # combined_course_run_1_content_key = 'combined_course_run_1' + # combined_course_run_2_content_key = 'combined_course_run_2' + # fully_restricted_course_content_key = 'fully_restricted_course' + # fully_restricted_course_run_1_content_key = 'fully_restricted_course_run_1' + # + # catalog = EnterpriseCatalogFactory() + # combined_course = ContentMetadataFactory(content_key=combined_course_content_key, content_type=COURSE) + # combined_course_run_1 = ContentMetadataFactory( + # content_key=combined_course_run_1_content_key, + # content_type=COURSE_RUN + # ) + # combined_course_run_2 = RestrictedCourseMetadataFactory( + # content_key=combined_course_run_2_content_key, + # content_type=COURSE_RUN, + # ) + # # TODO: create restricted course for mixed course + # fully_restricted_course = RestrictedCourseMetadataFactory(content_key=fully_restricted_course_content_key) + # combined_course_run_2 = RestrictedCourseMetadataFactory( + # content_key=combined_course_run_2_content_key, + # content_type=COURSE_RUN, + # ) + # fully_restricted_course_run_1 = RestrictedRunAllowedForRestrictedCourseFactory( + # content_key=fully_restricted_course_run_1_content_key + # ) + # for course_entity in [ + # combined_course, + # combined_course_run_2, + # fully_restricted_course, + # fully_restricted_course_run_1 + # ]: + # course_entity.catalog_queries.set(CatalogQuery.objects.all()) + # + # restricted_course_metadata, _ = RestrictedCourseMetadata.objects.get_or_create( + # content_key=fully_restricted_course.content_key, + # content_type=fully_restricted_course.content_type, + # ) + # restricted_course_metadata.catalog_query = CatalogQuery.objects.first() + # restricted_course_metadata.unrestricted_parent = fully_restricted_course + # restricted_course_metadata.save() + # + # self.assertIsNotNone(catalog.content_metadata[1].json_metadata) + # self.assertIsNone(catalog.content_metadata_with_restricted[2].json_metadata) + # self.assertIsNotNone(catalog.get_matching_content( + # [combined_course_content_key], + # include_restricted=False + # )[0].json_metadata) + # self.assertIsNotNone(catalog.get_matching_content( + # [combined_course_content_key], + # include_restricted=True + # )[0].json_metadata) + # self.assertIsNotNone(catalog.get_matching_content( + # [fully_restricted_course_content_key], + # include_restricted=False + # )[0].json_metadata) + # self.assertIsNone(catalog.get_matching_content( + # [fully_restricted_course_content_key], + # include_restricted=True + # )[0].json_metadata) + # + # self.assertEqual( + # len(catalog.get_matching_content([fully_restricted_course_run_1_content_key], include_restricted=False)), + # 0 + # ) + # self.assertEqual( + # len(catalog.get_matching_content([fully_restricted_course_run_1_content_key], include_restricted=True)), + # 1 + # ) """ - Verify the get_content_metadata endpoint returns all the metadata associated with a particular catalog + Test the get_content_metadata endpoint to verify that restricted content is properly + handled, both for restricted and unrestricted course runs, with learner portal enabled/disabled. """ + # Mock the return value of the EnterpriseApiClient to simulate the enterprise customer data. mock_api_client.return_value.get_enterprise_customer.return_value = { 'slug': self.enterprise_slug, 'enable_learner_portal': learner_portal_enabled, 'modified': str(datetime.now().replace(tzinfo=pytz.UTC)), } + + # Define content keys for the test combined_course_content_key = 'combined_course' combined_course_run_1_content_key = 'combined_course_run_1' combined_course_run_2_content_key = 'combined_course_run_2' fully_restricted_course_content_key = 'fully_restricted_course' fully_restricted_course_run_1_content_key = 'fully_restricted_course_run_1' + # Create a catalog catalog = EnterpriseCatalogFactory() - combined_course = ContentMetadataFactory(content_key=combined_course_content_key, content_type=COURSE) + + # Create unrestricted content (combined course and run 1) + combined_course = ContentMetadataFactory( + content_key=combined_course_content_key, + content_type=COURSE + ) combined_course_run_1 = ContentMetadataFactory( content_key=combined_course_run_1_content_key, - content_type=COURSE_RUN + content_type=COURSE_RUN, + parent_content_key=combined_course_content_key ) + + # Create restricted content (combined course run 2 and fully restricted course) combined_course_run_2 = RestrictedCourseMetadataFactory( content_key=combined_course_run_2_content_key, content_type=COURSE_RUN, + parent_content_key=combined_course_content_key + ) + fully_restricted_course = RestrictedCourseMetadataFactory( + content_key=fully_restricted_course_content_key, + content_type=COURSE ) - # TODO: create restricted course for mixed course - fully_restricted_course = RestrictedCourseMetadataFactory(content_key=fully_restricted_course_content_key) fully_restricted_course_run_1 = RestrictedRunAllowedForRestrictedCourseFactory( - content_key=fully_restricted_course_run_1_content_key + course=fully_restricted_course, + run=ContentMetadataFactory( + content_key=fully_restricted_course_run_1_content_key, + content_type=COURSE_RUN, + parent_content_key=fully_restricted_course_content_key + ) ) - for course_entity in [ - combined_course, - combined_course_run_2, - fully_restricted_course, - fully_restricted_course_run_1 - ]: - course_entity.catalog_queries.set(CatalogQuery.objects.all()) - restricted_course_metadata, _ = RestrictedCourseMetadata.objects.get_or_create( - content_key=fully_restricted_course.content_key, - content_type=fully_restricted_course.content_type, - ) - restricted_course_metadata.catalog_query = CatalogQuery.objects.first() - restricted_course_metadata.unrestricted_parent = fully_restricted_course - restricted_course_metadata.save() + # Associate the restricted content with the catalog by setting catalog_query + combined_course_run_2.catalog_query = catalog.catalog_query + combined_course_run_2.save() + fully_restricted_course.catalog_query = catalog.catalog_query + fully_restricted_course.save() + fully_restricted_course_run_1.run.catalog_query = catalog.catalog_query + fully_restricted_course_run_1.run.save() - self.assertIsNotNone(catalog.content_metadata[1].json_metadata) - self.assertIsNone(catalog.content_metadata_with_restricted[2].json_metadata) - self.assertIsNotNone(catalog.get_matching_content( + # Test unrestricted content retrieval with `include_restricted=False` + response_unrestricted = catalog.get_matching_content( [combined_course_content_key], include_restricted=False - )[0].json_metadata) - self.assertIsNotNone(catalog.get_matching_content( - [combined_course_content_key], - include_restricted=True - )[0].json_metadata) - self.assertIsNotNone(catalog.get_matching_content( + ) + self.assertTrue(len(response_unrestricted) > 0) + self.assertIn(combined_course_content_key, [item.content_key for item in response_unrestricted]) + + # Test restricted content is NOT retrieved when `include_restricted=False` + response_restricted = catalog.get_matching_content( [fully_restricted_course_content_key], include_restricted=False - )[0].json_metadata) - self.assertIsNone(catalog.get_matching_content( + ) + self.assertEqual(len(response_restricted), 0) + + # Test restricted content IS retrieved when `include_restricted=True` + response_with_restricted = catalog.get_matching_content( [fully_restricted_course_content_key], include_restricted=True - )[0].json_metadata) + ) + self.assertTrue(len(response_with_restricted) > 0) + self.assertIn(fully_restricted_course_content_key, [item.content_key for item in response_with_restricted]) - self.assertEqual( - len(catalog.get_matching_content([fully_restricted_course_run_1_content_key], include_restricted=False)), - 0 + # Test that the fully restricted course run is NOT retrieved with `include_restricted=False` + response_run_restricted_false = catalog.get_matching_content( + [fully_restricted_course_run_1_content_key], + include_restricted=False ) - self.assertEqual( - len(catalog.get_matching_content([fully_restricted_course_run_1_content_key], include_restricted=True)), - 1 + self.assertEqual(len(response_run_restricted_false), 0) + + # Test that the fully restricted course run IS retrieved with `include_restricted=True` + response_run_restricted_true = catalog.get_matching_content( + [fully_restricted_course_run_1_content_key], + include_restricted=True ) + self.assertTrue(len(response_run_restricted_true) > 0) + self.assertIn(fully_restricted_course_run_1_content_key, + [item.content_key for item in response_run_restricted_true])