Skip to content

Commit 624a6fb

Browse files
committed
feat: v2 catalog contains_content_items view
ENT-9408
1 parent 5425510 commit 624a6fb

7 files changed

+277
-18
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from rest_framework.reverse import reverse
2+
3+
from enterprise_catalog.apps.api.v1.tests.mixins import APITestMixin
4+
from enterprise_catalog.apps.catalog.models import (
5+
CatalogQuery,
6+
ContentMetadata,
7+
EnterpriseCatalog,
8+
)
9+
from enterprise_catalog.apps.catalog.tests.factories import (
10+
EnterpriseCatalogFactory,
11+
)
12+
13+
14+
class BaseEnterpriseCatalogViewSetTests(APITestMixin):
15+
"""
16+
Base tests for EnterpriseCatalog view sets.
17+
"""
18+
VERSION = 'v1'
19+
20+
def setUp(self):
21+
super().setUp()
22+
# clean up any stale test objects
23+
CatalogQuery.objects.all().delete()
24+
ContentMetadata.objects.all().delete()
25+
EnterpriseCatalog.objects.all().delete()
26+
27+
self.enterprise_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid)
28+
29+
# Set up catalog.has_learner_access permissions
30+
self.set_up_catalog_learner()
31+
32+
def tearDown(self):
33+
super().tearDown()
34+
# clean up any stale test objects
35+
CatalogQuery.objects.all().delete()
36+
ContentMetadata.objects.all().delete()
37+
EnterpriseCatalog.objects.all().delete()
38+
39+
def _get_contains_content_base_url(self, catalog_uuid=None):
40+
"""
41+
Helper to construct the base url for the catalog contains_content_items endpoint
42+
"""
43+
return reverse(
44+
f'api:{self.VERSION}:enterprise-catalog-content-contains-content-items',
45+
kwargs={'uuid': catalog_uuid or self.enterprise_catalog.uuid},
46+
)

enterprise_catalog/apps/api/v1/views/enterprise_catalog_contains_content_items.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ def get_permission_object(self):
4141
return str(enterprise_catalog.enterprise_uuid)
4242
return None
4343

44+
def catalog_contains_content_items(self, content_keys):
45+
"""
46+
Returns a boolean indicating whether all of the provided content_keys
47+
are contained by the catalog record associated with the current request.
48+
"""
49+
enterprise_catalog = self.get_object()
50+
return enterprise_catalog.contains_content_keys(content_keys)
51+
4452
# Becuase the edx-rbac perms are built around a part of the URL
4553
# path, here (the uuid of the catalog), we can utilize per-view caching,
4654
# rather than per-user caching.
@@ -56,6 +64,6 @@ def contains_content_items(self, request, uuid, course_run_ids, program_uuids, *
5664
"""
5765
course_run_ids = unquote_course_keys(course_run_ids)
5866

59-
enterprise_catalog = self.get_object()
60-
contains_content_items = enterprise_catalog.contains_content_keys(course_run_ids + program_uuids)
61-
return Response({'contains_content_items': contains_content_items})
67+
return Response({
68+
'contains_content_items': self.catalog_contains_content_items(course_run_ids + program_uuids),
69+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import uuid
2+
from datetime import datetime, timedelta
3+
from unittest import mock
4+
5+
import ddt
6+
import pytest
7+
import pytz
8+
from rest_framework import status
9+
10+
from enterprise_catalog.apps.api.base.tests.enterprise_catalog_views import (
11+
BaseEnterpriseCatalogViewSetTests,
12+
)
13+
from enterprise_catalog.apps.catalog.constants import (
14+
COURSE,
15+
COURSE_RUN,
16+
RESTRICTED_RUNS_ALLOWED_KEY,
17+
)
18+
from enterprise_catalog.apps.catalog.tests.factories import (
19+
ContentMetadataFactory,
20+
EnterpriseCatalogFactory,
21+
RestrictedCourseMetadataFactory,
22+
RestrictedRunAllowedForRestrictedCourseFactory,
23+
)
24+
from enterprise_catalog.apps.catalog.utils import localized_utcnow
25+
26+
27+
@ddt.ddt
28+
class EnterpriseCatalogContainsContentItemsTests(BaseEnterpriseCatalogViewSetTests):
29+
"""
30+
Tests for the EnterpriseCatalogViewSetV2, which is permissive of restricted course/run metadata.
31+
"""
32+
VERSION = 'v2'
33+
34+
def setUp(self):
35+
super().setUp()
36+
37+
self.customer_details_patcher = mock.patch(
38+
'enterprise_catalog.apps.catalog.models.EnterpriseCustomerDetails'
39+
)
40+
self.mock_customer_details = self.customer_details_patcher.start()
41+
self.NOW = localized_utcnow()
42+
self.mock_customer_details.return_value.last_modified_date = self.NOW
43+
44+
self.addCleanup(self.customer_details_patcher.stop)
45+
46+
def test_contains_content_items_unauthorized_non_catalog_learner(self):
47+
"""
48+
Verify the contains_content_items endpoint rejects users that are not catalog learners
49+
"""
50+
self.set_up_invalid_jwt_role()
51+
self.remove_role_assignments()
52+
url = self._get_contains_content_base_url() + '?course_run_ids=fakeX'
53+
response = self.client.get(url)
54+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
55+
56+
def test_contains_content_items_unauthorized_incorrect_jwt_context(self):
57+
"""
58+
Verify the contains_content_items endpoint rejects users that are catalog learners
59+
with an incorrect JWT context (i.e., enterprise uuid)
60+
"""
61+
other_customer_catalog = EnterpriseCatalogFactory(enterprise_uuid=uuid.uuid4())
62+
63+
base_url = self._get_contains_content_base_url(other_customer_catalog.uuid)
64+
url = base_url + '?course_run_ids=fakeX'
65+
response = self.client.get(url)
66+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
67+
68+
def test_contains_content_items_implicit_access(self):
69+
"""
70+
Verify the contains_content_items endpoint responds with 200 OK for
71+
user with implicit JWT access
72+
"""
73+
self.remove_role_assignments()
74+
url = self._get_contains_content_base_url() + '?program_uuids=fakeX'
75+
self.assert_correct_contains_response(url, False)
76+
77+
def test_contains_content_items_no_params(self):
78+
"""
79+
Verify the contains_content_items endpoint errors if no parameters are provided
80+
"""
81+
response = self.client.get(self._get_contains_content_base_url())
82+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
83+
84+
def test_contains_content_items_not_in_catalogs(self):
85+
"""
86+
Verify the contains_content_items endpoint returns False if the content is not in any associated catalog
87+
"""
88+
self.add_metadata_to_catalog(self.enterprise_catalog, [ContentMetadataFactory()])
89+
90+
url = self._get_contains_content_base_url() + '?program_uuids=this-is-not-the-uuid-youre-looking-for'
91+
self.assert_correct_contains_response(url, False)
92+
93+
def test_contains_content_items_in_catalogs(self):
94+
"""
95+
Verify the contains_content_items endpoint returns True if the content is in any associated catalog
96+
"""
97+
content_key = 'fake-key+101x'
98+
relevant_content = ContentMetadataFactory(content_key=content_key)
99+
self.add_metadata_to_catalog(self.enterprise_catalog, [relevant_content])
100+
101+
url = self._get_contains_content_base_url() + '?course_run_ids=' + content_key
102+
self.assert_correct_contains_response(url, True)
103+
104+
def _create_restricted_course_and_run(self, catalog):
105+
"""
106+
Helper to setup restricted course and run.
107+
"""
108+
content_one = ContentMetadataFactory(content_key='org+key1', content_type=COURSE)
109+
restricted_course = RestrictedCourseMetadataFactory.create(
110+
content_key='org+key1',
111+
content_type=COURSE,
112+
unrestricted_parent=content_one,
113+
catalog_query=catalog.catalog_query,
114+
_json_metadata=content_one.json_metadata,
115+
)
116+
restricted_run = ContentMetadataFactory.create(
117+
content_key='course-v1:org+key1+restrictedrun',
118+
parent_content_key=restricted_course.content_key,
119+
content_type=COURSE_RUN,
120+
)
121+
restricted_course.restricted_run_allowed_for_restricted_course.set(
122+
[restricted_run], clear=True,
123+
)
124+
catalog.catalog_query.content_filter[RESTRICTED_RUNS_ALLOWED_KEY] = {
125+
content_one.content_key: [restricted_run.content_key],
126+
}
127+
catalog.catalog_query.save()
128+
return content_one, restricted_course, restricted_run
129+
130+
def test_contains_catalog_key_restricted_runs_allowed(self):
131+
"""
132+
Tests that a catalog is considered to contain a restricted run,
133+
and that a different catalog that does *not* allow the restricted run
134+
is not considered to contain it.
135+
"""
136+
other_catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid)
137+
content_one, _, restricted_run = self._create_restricted_course_and_run(other_catalog)
138+
139+
self.add_metadata_to_catalog(self.enterprise_catalog, [content_one])
140+
self.add_metadata_to_catalog(other_catalog, [content_one])
141+
142+
url = self._get_contains_content_base_url(other_catalog.uuid) + \
143+
f'?course_run_ids={restricted_run.content_key}'
144+
145+
response = self.client.get(url)
146+
response_payload = response.json()
147+
148+
self.assertTrue(response_payload.get('contains_content_items'))
149+
150+
# self.enterprise_catalog does not contain the restricted run.
151+
url = self._get_contains_content_base_url(self.enterprise_catalog) + \
152+
f'?course_run_ids={restricted_run.content_key}'
153+
154+
response = self.client.get(url)
155+
response_payload = response.json()
156+
157+
self.assertFalse(response_payload.get('contains_content_items'))

enterprise_catalog/apps/api/v2/tests/test_enterprise_customer_views.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,20 @@ def _create_restricted_course_and_run(self, catalog):
115115
content_type=COURSE,
116116
unrestricted_parent=content_one,
117117
catalog_query=catalog.catalog_query,
118+
_json_metadata=content_one.json_metadata,
118119
)
119120
restricted_run = ContentMetadataFactory.create(
120121
content_key='course-v1:org+key1+restrictedrun',
121122
content_type=COURSE_RUN,
123+
parent_content_key=restricted_course.content_key,
122124
)
123125
restricted_course.restricted_run_allowed_for_restricted_course.set(
124126
[restricted_run], clear=True,
125127
)
128+
catalog.catalog_query.content_filter[RESTRICTED_RUNS_ALLOWED_KEY] = {
129+
content_one.content_key: [restricted_run.content_key],
130+
}
131+
catalog.catalog_query.save()
126132
return content_one, restricted_course, restricted_run
127133

128134
def test_contains_catalog_key_restricted_runs_allowed(self):
@@ -134,8 +140,9 @@ def test_contains_catalog_key_restricted_runs_allowed(self):
134140

135141
content_one, _, restricted_run = self._create_restricted_course_and_run(catalog)
136142

137-
self.add_metadata_to_catalog(catalog, [content_one, restricted_run])
138-
self.add_metadata_to_catalog(catalog_b, [content_one])
143+
self.add_metadata_to_catalog(catalog, [content_one])
144+
# add the top-level course to catalog_b, too
145+
self.add_metadata_to_catalog(catalog, [content_one])
139146

140147
url = self._get_contains_content_base_url() + \
141148
f'?course_run_ids={restricted_run.content_key}&get_catalogs_containing_specified_content_ids=true'
@@ -210,14 +217,10 @@ def test_get_content_metadata_restricted_runs(self):
210217
Tests that we can retrieve restricted content metadata for a customer.
211218
"""
212219
catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid)
213-
catalog_b = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid)
214220

215221
content_one, _, restricted_run = self._create_restricted_course_and_run(catalog)
216222

217-
self.add_metadata_to_catalog(catalog, [content_one, restricted_run])
218-
219-
# add only the top-level course to catalog B
220-
self.add_metadata_to_catalog(catalog_b, [content_one])
223+
self.add_metadata_to_catalog(catalog, [content_one])
221224

222225
# Test that we can retrieve the course record
223226
url = self._get_content_metadata_base_url(self.enterprise_uuid, content_one.content_key)
@@ -230,27 +233,30 @@ def test_get_content_metadata_restricted_runs(self):
230233
url = self._get_content_metadata_base_url(self.enterprise_uuid, restricted_run.content_key)
231234

232235
response_payload = self.client.get(url).json()
233-
self.assertEqual(response_payload['key'], restricted_run.content_key)
234-
self.assertEqual(response_payload['content_type'], COURSE_RUN)
236+
# this will be a top-level course, with course_runs nested within it
237+
self.assertEqual(response_payload['key'], content_one.content_key)
238+
self.assertEqual(response_payload['content_type'], COURSE)
235239

236240
# Test that we can retrieve the restricted run by uuid
237241
url = self._get_content_metadata_base_url(self.enterprise_uuid, restricted_run.content_uuid)
238242

239243
response_payload = self.client.get(url).json()
240-
self.assertEqual(response_payload['uuid'], str(restricted_run.content_uuid))
241-
self.assertEqual(response_payload['content_type'], COURSE_RUN)
244+
# this will be a top-level course, with course_runs nested within it
245+
self.assertEqual(response_payload['key'], content_one.content_key)
246+
self.assertEqual(response_payload['content_type'], COURSE)
242247

243248
def test_get_content_metadata_restricted_runs_not_found(self):
244249
"""
245250
Tests that when restricted runs are not explicitly linked to a customer's catalog,
246251
they cannot be retrieved.
247252
"""
248253
catalog = EnterpriseCatalogFactory(enterprise_uuid=self.enterprise_uuid)
254+
another_customers_catalog = EnterpriseCatalogFactory(enterprise_uuid=str(uuid.uuid4()))
249255

250-
content_one, _, restricted_run = self._create_restricted_course_and_run(catalog)
256+
content_one, _, restricted_run = self._create_restricted_course_and_run(another_customers_catalog)
251257

252-
# don't add the restricted run to the catalog, just the plain, top-level course
253258
self.add_metadata_to_catalog(catalog, [content_one])
259+
self.add_metadata_to_catalog(another_customers_catalog, [content_one])
254260

255261
# Test that we can retrieve the course record
256262
url = self._get_content_metadata_base_url(self.enterprise_uuid, content_one.content_key)

enterprise_catalog/apps/api/v2/urls.py

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from django.urls import path, re_path
55
from rest_framework.routers import DefaultRouter
66

7+
from enterprise_catalog.apps.api.v2.views.enterprise_catalog_contains_content_items import (
8+
EnterpriseCatalogContainsContentItemsV2,
9+
)
710
from enterprise_catalog.apps.api.v2.views.enterprise_catalog_get_content_metadata import (
811
EnterpriseCatalogGetContentMetadataV2,
912
)
@@ -17,6 +20,7 @@
1720
router = DefaultRouter()
1821

1922
router.register(r'enterprise-customer', EnterpriseCustomerViewSetV2, basename='enterprise-customer')
23+
router.register(r'enterprise-catalogs', EnterpriseCatalogContainsContentItemsV2, basename='enterprise-catalog-content')
2024

2125
urlpatterns = [
2226
re_path(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import logging
2+
3+
from enterprise_catalog.apps.api.v1.views.enterprise_catalog_contains_content_items import (
4+
EnterpriseCatalogContainsContentItems,
5+
)
6+
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class EnterpriseCatalogContainsContentItemsV2(EnterpriseCatalogContainsContentItems):
12+
"""
13+
Viewset to indicate if given content keys are contained by a catalog, with
14+
restricted content taken into account.
15+
"""
16+
def catalog_contains_content_items(self, content_keys):
17+
"""
18+
Returns a boolean indicating whether all of the provided content_keys
19+
are contained by the catalog record associated with the current request.
20+
Takes restricted content into account.
21+
"""
22+
enterprise_catalog = self.get_object()
23+
return enterprise_catalog.contains_content_keys(content_keys, include_restricted=True)

enterprise_catalog/apps/api/v2/views/enterprise_customer.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from enterprise_catalog.apps.api.v1.views.enterprise_customer import (
44
EnterpriseCustomerViewSet,
55
)
6+
from enterprise_catalog.apps.catalog.models import ContentMetadata
67

78

89
logger = logging.getLogger(__name__)
@@ -13,10 +14,24 @@ class EnterpriseCustomerViewSetV2(EnterpriseCustomerViewSet):
1314
V2 views for content metadata and catalog-content inclusion for retrieving.
1415
"""
1516
def get_metadata_by_uuid(self, catalog, content_uuid):
16-
return catalog.content_metadata_with_restricted.filter(content_uuid=content_uuid).first()
17+
"""
18+
Slightly more complicated - we have to find the content metadata
19+
record, regardless of catalog, with this uuid, then use `get_matching_content`
20+
on that record's content key.
21+
"""
22+
record = ContentMetadata.objects.filter(content_uuid=content_uuid).first()
23+
if not record:
24+
return
25+
return catalog.get_matching_content(
26+
content_keys=[record.content_key],
27+
include_restricted=True,
28+
).first()
1729

1830
def get_metadata_by_content_key(self, catalog, content_key):
19-
return catalog.get_matching_content(content_keys=[content_key], include_restricted=True).first()
31+
return catalog.get_matching_content(
32+
content_keys=[content_key],
33+
include_restricted=True,
34+
).first()
2035

2136
def filter_content_keys(self, catalog, content_keys):
2237
return catalog.filter_content_keys(content_keys, include_restricted=True)

0 commit comments

Comments
 (0)