Skip to content

Commit 8081509

Browse files
Merge pull request #837 from openedx/asheehan-edx/ENT-8661
feat: new catalog query read only viewset
2 parents d0895c1 + d6f2b25 commit 8081509

File tree

6 files changed

+245
-3
lines changed

6 files changed

+245
-3
lines changed

enterprise_catalog/apps/api/v1/serializers.py

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from re import search
2+
from re import findall, search
33

44
from django.db import IntegrityError, models
55
from rest_framework import serializers, status
@@ -85,6 +85,25 @@ def find_and_modify_catalog_query(
8585
return content_filter_from_hash
8686

8787

88+
class CatalogQuerySerializer(serializers.ModelSerializer):
89+
"""
90+
Serializer for the `CatalogQuery` model
91+
"""
92+
content_filter = serializers.JSONField(
93+
read_only=True,
94+
help_text="Elastic search content filter used to determine content ownership of the query."
95+
)
96+
97+
class Meta:
98+
model = CatalogQuery
99+
fields = [
100+
'uuid',
101+
'content_filter',
102+
'content_filter_hash',
103+
'title',
104+
]
105+
106+
88107
class EnterpriseCatalogSerializer(serializers.ModelSerializer):
89108
"""
90109
Serializer for the `EnterpriseCatalog` model
@@ -191,6 +210,18 @@ def update(self, instance, validated_data):
191210
"""
192211

193212

213+
class CatalogQueryGetByHashRequestSerializer(ImmutableStateSerializer):
214+
"""
215+
Request serializer to validate request data provided to the CatalogQueryViewSet's ``get_query_by_hash`` endpoint
216+
"""
217+
hash = serializers.CharField(required=True, max_length=32)
218+
219+
def validate_hash(self, value):
220+
if not findall(r"([a-fA-F\d]{32})", value):
221+
raise serializers.ValidationError("Invalid filter hash.")
222+
return value
223+
224+
194225
class ContentMetadataSerializer(ImmutableStateSerializer):
195226
"""
196227
Serializer for rendering Content Metadata objects

enterprise_catalog/apps/api/v1/tests/test_views.py

+138
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545
from enterprise_catalog.apps.catalog.utils import (
4646
enterprise_proxy_login_url,
47+
get_content_filter_hash,
4748
get_content_key,
4849
get_parent_content_key,
4950
localized_utcnow,
@@ -2499,3 +2500,140 @@ def test_filter_list_by_uuid(self):
24992500
assert len(response_json.get('results')) == 1
25002501
assert response_json.get('results')[0].get("key") == self.content_metadata_object.content_key
25012502
assert response_json.get('results')[0].get("course_runs")[0].get('start') == '2024-02-12T11:00:00Z'
2503+
2504+
2505+
@ddt.ddt
2506+
class CatalogQueryViewTests(APITestMixin):
2507+
"""
2508+
Tests for the readonly ContentMetadata viewset.
2509+
"""
2510+
def setUp(self):
2511+
super().setUp()
2512+
self.set_up_catalog_learner()
2513+
self.catalog_query_object = CatalogQueryFactory()
2514+
self.catalog_object = EnterpriseCatalogFactory(catalog_query=self.catalog_query_object)
2515+
self.assign_catalog_admin_feature_role(enterprise_uuids=[self.catalog_object.enterprise_uuid])
2516+
# Factory doesn't set up a hash, so do it manually
2517+
self.catalog_query_object.content_filter_hash = get_content_filter_hash(
2518+
self.catalog_query_object.content_filter
2519+
)
2520+
self.catalog_query_object.save()
2521+
2522+
def test_get_query_by_hash(self):
2523+
"""
2524+
Test that the list content_identifiers query param accepts uuids
2525+
"""
2526+
query_param_string = f"?hash={self.catalog_query_object.content_filter_hash}"
2527+
url = reverse('api:v1:get-query-by-hash') + query_param_string
2528+
response = self.client.get(url)
2529+
response_json = response.json()
2530+
# The user is a part of the enterprise that has a catalog that contains this query
2531+
# so they can view the data
2532+
assert response_json.get('uuid') == str(self.catalog_query_object.uuid)
2533+
assert str(response_json.get('content_filter')) == str(self.catalog_query_object.content_filter)
2534+
2535+
# Permissions verification while looking up by hash
2536+
different_catalog = EnterpriseCatalogFactory()
2537+
# Factory doesn't set up a hash, so do it manually
2538+
different_catalog.catalog_query.content_filter_hash = get_content_filter_hash(
2539+
different_catalog.catalog_query.content_filter
2540+
)
2541+
different_catalog.save()
2542+
query_param_string = f"?hash={different_catalog.catalog_query.content_filter_hash}"
2543+
url = reverse('api:v1:get-query-by-hash') + query_param_string
2544+
response = self.client.get(url)
2545+
response_json = response.json()
2546+
assert response_json == {'detail': 'Catalog query not found.'}
2547+
2548+
# If the user is staff, they get access to everything
2549+
self.set_up_staff()
2550+
response = self.client.get(url)
2551+
response_json = response.json()
2552+
assert response_json.get('uuid') == str(different_catalog.catalog_query.uuid)
2553+
2554+
self.set_up_invalid_jwt_role()
2555+
self.remove_role_assignments()
2556+
response = self.client.get(url)
2557+
assert response.status_code == 404
2558+
2559+
def test_get_query_by_hash_not_found(self):
2560+
"""
2561+
Test that the get query by hash endpoint returns expected not found
2562+
"""
2563+
query_param_string = f"?hash={self.catalog_query_object.content_filter_hash[:-6]}aaaaaa"
2564+
url = reverse('api:v1:get-query-by-hash') + query_param_string
2565+
response = self.client.get(url)
2566+
response_json = response.json()
2567+
assert response_json == {'detail': 'Catalog query not found.'}
2568+
2569+
def test_get_query_by_illegal_hash(self):
2570+
"""
2571+
Test that the get query by hash endpoint validates filter hashes
2572+
"""
2573+
query_param_string = "?hash=foobar"
2574+
url = reverse('api:v1:get-query-by-hash') + query_param_string
2575+
response = self.client.get(url)
2576+
response_json = response.json()
2577+
assert response_json == {'hash': ['Invalid filter hash.']}
2578+
2579+
def test_get_query_by_hash_requires_hash(self):
2580+
"""
2581+
Test that the get query by hash requires a hash query param
2582+
"""
2583+
url = reverse('api:v1:get-query-by-hash')
2584+
response = self.client.get(url)
2585+
response_json = response.json()
2586+
assert response_json == ['You must provide at least one of the following query parameters: hash.']
2587+
2588+
def test_catalog_query_retrieve(self):
2589+
"""
2590+
Test that the Catalog Query viewset supports retrieving individual queries
2591+
"""
2592+
self.assign_catalog_admin_jwt_role(
2593+
self.enterprise_uuid,
2594+
self.catalog_query_object.enterprise_catalogs.first().enterprise_uuid,
2595+
)
2596+
url = reverse('api:v1:catalog-queries-detail', kwargs={'pk': self.catalog_query_object.pk})
2597+
response = self.client.get(url)
2598+
response_json = response.json()
2599+
assert response_json.get('uuid') == str(self.catalog_query_object.uuid)
2600+
2601+
different_customer_catalog = EnterpriseCatalogFactory()
2602+
# We don't have a jwt token that includes an admin role for the new enterprise so it is
2603+
# essentially hidden to the requester
2604+
url = reverse('api:v1:catalog-queries-detail', kwargs={'pk': different_customer_catalog.catalog_query.pk})
2605+
response = self.client.get(url)
2606+
assert response.status_code == 404
2607+
2608+
# If the user is staff, they get access to everything
2609+
self.set_up_staff()
2610+
response = self.client.get(url)
2611+
response_json = response.json()
2612+
assert response_json.get('uuid') == str(different_customer_catalog.catalog_query.uuid)
2613+
2614+
def test_catalog_query_list(self):
2615+
"""
2616+
Test that the Catalog Query viewset supports listing queries
2617+
"""
2618+
# Create another catalog associated with another enterprise and therefore hidden to the requesting user
2619+
EnterpriseCatalogFactory()
2620+
self.assign_catalog_admin_jwt_role(
2621+
self.enterprise_uuid,
2622+
self.catalog_query_object.enterprise_catalogs.first().enterprise_uuid,
2623+
)
2624+
url = reverse('api:v1:catalog-queries-list')
2625+
response = self.client.get(url)
2626+
response_json = response.json()
2627+
assert response_json.get('count') == 1
2628+
assert response_json.get('results')[0].get('uuid') == str(self.catalog_query_object.uuid)
2629+
2630+
# If the user is staff, they get access to everything
2631+
self.set_up_staff()
2632+
response = self.client.get(url)
2633+
response_json = response.json()
2634+
assert response_json.get('count') == 2
2635+
2636+
self.set_up_invalid_jwt_role()
2637+
self.remove_role_assignments()
2638+
response = self.client.get(url)
2639+
assert response.data == {'count': 0, 'next': None, 'previous': None, 'results': []}

enterprise_catalog/apps/api/v1/urls.py

+9
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from enterprise_catalog.apps.api.v1.views.catalog_csv_data import (
1212
CatalogCsvDataView,
1313
)
14+
from enterprise_catalog.apps.api.v1.views.catalog_query import (
15+
CatalogQueryViewSet,
16+
)
1417
from enterprise_catalog.apps.api.v1.views.catalog_workbook import (
1518
CatalogWorkbookView,
1619
)
@@ -61,6 +64,7 @@
6164
router.register(r'highlight-sets-admin', HighlightSetViewSet, basename='highlight-sets-admin')
6265
router.register(r'academies', AcademiesReadOnlyViewSet, basename='academies')
6366
router.register(r'content-metadata', ContentMetadataView, basename='content-metadata')
67+
router.register(r'catalog-queries', CatalogQueryViewSet, basename='catalog-queries')
6468

6569
urlpatterns = [
6670
path('enterprise-catalogs/catalog_csv_data', CatalogCsvDataView.as_view(),
@@ -106,6 +110,11 @@
106110
EnterpriseCustomerViewSet.as_view({'get': 'content_metadata'}),
107111
name='customer-content-metadata-retrieve'
108112
),
113+
path(
114+
'catalog-queries/get_query_by_hash',
115+
CatalogQueryViewSet.as_view({'get': 'get_query_by_hash'}),
116+
name='get-query-by-hash'
117+
),
109118
]
110119

111120
urlpatterns += router.urls
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from django.utils.decorators import method_decorator
2+
from django.utils.functional import cached_property
3+
from rest_framework import viewsets
4+
from rest_framework.decorators import action
5+
from rest_framework.exceptions import NotFound
6+
from rest_framework.renderers import JSONRenderer
7+
from rest_framework.response import Response
8+
9+
from enterprise_catalog.apps.api.v1.decorators import (
10+
require_at_least_one_query_parameter,
11+
)
12+
from enterprise_catalog.apps.api.v1.serializers import (
13+
CatalogQueryGetByHashRequestSerializer,
14+
CatalogQuerySerializer,
15+
)
16+
from enterprise_catalog.apps.catalog.models import CatalogQuery
17+
from enterprise_catalog.apps.catalog.rules import (
18+
enterprises_with_admin_access,
19+
has_access_to_all_enterprises,
20+
)
21+
22+
23+
class CatalogQueryViewSet(viewsets.ReadOnlyModelViewSet):
24+
"""Read-only viewset for Catalog Query records"""
25+
renderer_classes = [JSONRenderer]
26+
serializer_class = CatalogQuerySerializer
27+
28+
@cached_property
29+
def admin_accessible_enterprises(self):
30+
"""
31+
Cached set of enterprise identifiers the requesting user has admin access to.
32+
"""
33+
return enterprises_with_admin_access(self.request)
34+
35+
def get_queryset(self):
36+
"""
37+
Restrict the queryset to catalog queries the requesting user has access to. Iff the user is staff they have
38+
access to all queries.
39+
"""
40+
all_queries = CatalogQuery.objects.all()
41+
if not self.admin_accessible_enterprises:
42+
return CatalogQuery.objects.none()
43+
if has_access_to_all_enterprises(self.admin_accessible_enterprises) or self.request.user.is_staff:
44+
return all_queries
45+
return all_queries.filter(
46+
enterprise_catalogs__enterprise_uuid__in=self.admin_accessible_enterprises
47+
)
48+
49+
@method_decorator(require_at_least_one_query_parameter('hash'))
50+
@action(detail=True, methods=['get'])
51+
def get_query_by_hash(self, request, **kwargs):
52+
"""
53+
Fetch a Catalog Query by its hash. The hash values are a product of Python's ``hashlib``'s md5 algorithm
54+
in hexdigest representation.
55+
"""
56+
request_serializer = CatalogQueryGetByHashRequestSerializer(data=request.query_params)
57+
request_serializer.is_valid(raise_exception=True)
58+
content_filter_hash = request_serializer.validated_data.get('hash')
59+
try:
60+
query = self.get_queryset().get(content_filter_hash=content_filter_hash)
61+
except CatalogQuery.DoesNotExist as exc:
62+
raise NotFound('Catalog query not found.') from exc
63+
serialized_data = self.serializer_class(query)
64+
return Response(serialized_data.data)

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def get_serializer_class(self):
4545

4646
def get_permission_object(self):
4747
"""
48-
Retrieves the apporpriate object to use during edx-rbac's permission checks.
48+
Retrieves the appropriate object to use during edx-rbac's permission checks.
4949
5050
This object is passed to the rule predicate(s).
5151
"""

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def check_permissions(self, request):
5858

5959
def get_permission_object(self):
6060
"""
61-
Retrieves the apporpriate object to use during edx-rbac's permission checks.
61+
Retrieves the appropriate object to use during edx-rbac's permission checks.
6262
6363
This object is passed to the rule predicate(s).
6464
"""

0 commit comments

Comments
 (0)