Skip to content

Commit 18de80f

Browse files
feat(bffs): add multi-license entitlement handling
1 parent 9b6e0ae commit 18de80f

File tree

15 files changed

+1717
-124
lines changed

15 files changed

+1717
-124
lines changed

enterprise_access/apps/api/v1/tests/test_bff_views.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,38 @@ def test_dashboard_with_subscriptions(
340340
})
341341
assert check_objects(response.json(), expected_response_data)
342342

343+
@mock_dashboard_dependencies
344+
def test_dashboard_with_activated_license_fetches_secured_algolia_key_once(
345+
self,
346+
mock_get_enterprise_customers_for_user,
347+
mock_get_secured_algolia_api_key_for_user,
348+
mock_get_default_enrollment_intentions_learner_status,
349+
mock_get_subscription_licenses_for_learner,
350+
mock_get_enterprise_course_enrollments,
351+
):
352+
"""Learner-portal routes should not perform duplicate unscoped+scoped Algolia fetches."""
353+
self.set_jwt_cookie([{
354+
'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE,
355+
'context': self.mock_enterprise_customer_uuid,
356+
}])
357+
mock_get_enterprise_customers_for_user.return_value = self.mock_enterprise_learner_response_data
358+
mock_get_secured_algolia_api_key_for_user.return_value = self.mock_secured_algolia_api_key_response
359+
mock_get_subscription_licenses_for_learner.return_value = {
360+
'customer_agreement': self.mock_customer_agreement,
361+
'results': [self.mock_subscription_license],
362+
}
363+
mock_get_default_enrollment_intentions_learner_status.return_value = \
364+
self.mock_default_enterprise_enrollment_intentions_learner_status_data
365+
mock_get_enterprise_course_enrollments.return_value = self.mock_enterprise_course_enrollments
366+
367+
dashboard_url = reverse('api:v1:learner-portal-bff-dashboard')
368+
dashboard_url += f"?{urlencode({'enterprise_customer_slug': self.mock_enterprise_customer_slug})}"
369+
370+
response = self.client.post(dashboard_url)
371+
372+
self.assertEqual(response.status_code, status.HTTP_200_OK)
373+
mock_get_secured_algolia_api_key_for_user.assert_called_once()
374+
343375
@mock_dashboard_dependencies
344376
@mock.patch('enterprise_access.apps.api_client.license_manager_client.LicenseManagerUserApiClient.activate_license')
345377
def test_dashboard_with_subscriptions_license_activation(

enterprise_access/apps/api/v1/views/bffs/common.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,22 @@ class BaseBFFViewSetMixin:
4444
Mixin class containing common BFF viewset functionality.
4545
"""
4646

47-
def _create_context(self, request, context_class):
47+
def _create_context(self, request, context_class, context_kwargs=None):
4848
"""
4949
Creates the appropriate context for the request.
5050
5151
Args:
5252
request: The incoming HTTP request
5353
context_class: The context class to instantiate
54+
context_kwargs (dict | None): Extra keyword arguments forwarded to the
55+
context constructor (e.g. ``initialize_secured_algolia_api_keys=False``).
5456
5557
Returns:
5658
tuple: (context_instance, error_response_data, error_status_code)
5759
If successful, error_response_data and error_status_code will be None
5860
"""
5961
try:
60-
context = context_class(request=request)
62+
context = context_class(request=request, **(context_kwargs or {}))
6163
return context, None, None
6264
except Exception as exc: # pylint: disable=broad-except
6365
logger.exception("Could not instantiate the handler context for the request.")
@@ -157,7 +159,14 @@ def _build_response(self, context, response_builder_class):
157159

158160
return dict(ordered_representation), status_code
159161

160-
def load_route_data_and_build_response(self, request, handler_class, response_builder_class, context_class):
162+
def load_route_data_and_build_response(
163+
self,
164+
request,
165+
handler_class,
166+
response_builder_class,
167+
context_class,
168+
context_kwargs=None,
169+
):
161170
"""
162171
Handles the route and builds the response with the specified context class.
163172
@@ -166,12 +175,16 @@ def load_route_data_and_build_response(self, request, handler_class, response_bu
166175
handler_class: The handler class to use
167176
response_builder_class: The response builder class to use
168177
context_class: The context class to use
178+
context_kwargs (dict | None): Extra keyword arguments forwarded to the
179+
context constructor (e.g. ``initialize_secured_algolia_api_keys=False``).
169180
170181
Returns:
171182
tuple: (response_data, status_code)
172183
"""
173184
# Create the context based on the request
174-
context, error_response, error_status = self._create_context(request, context_class)
185+
context, error_response, error_status = self._create_context(
186+
request, context_class, context_kwargs=context_kwargs,
187+
)
175188
if context is None:
176189
return error_response, error_status
177190

@@ -191,12 +204,22 @@ class BaseBFFViewSet(BaseBFFViewSetMixin, ViewSet):
191204
authentication_classes = [JwtAuthentication]
192205
permission_classes = [IsAuthenticated]
193206

194-
def load_route_data_and_build_response(self, request, handler_class, response_builder_class):
207+
def load_route_data_and_build_response(
208+
self,
209+
request,
210+
handler_class,
211+
response_builder_class,
212+
context_kwargs=None,
213+
):
195214
"""
196215
Handles the route and builds the response using HandlerContext for authenticated requests.
197216
"""
198217
return super().load_route_data_and_build_response(
199-
request, handler_class, response_builder_class, HandlerContext
218+
request,
219+
handler_class,
220+
response_builder_class,
221+
HandlerContext,
222+
context_kwargs=context_kwargs,
200223
)
201224

202225

enterprise_access/apps/api/v1/views/bffs/learner_portal.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def dashboard(self, request, *args, **kwargs):
6262
request=request,
6363
handler_class=DashboardHandler,
6464
response_builder_class=LearnerDashboardResponseBuilder,
65+
context_kwargs={'initialize_secured_algolia_api_keys': False},
6566
)
6667
return Response(response_data, status=status_code)
6768

@@ -92,6 +93,7 @@ def search(self, request, *args, **kwargs):
9293
request=request,
9394
handler_class=SearchHandler,
9495
response_builder_class=LearnerSearchResponseBuilder,
96+
context_kwargs={'initialize_secured_algolia_api_keys': False},
9597
)
9698
return Response(response_data, status=status_code)
9799

@@ -122,6 +124,7 @@ def academy(self, request, *args, **kwargs):
122124
request=request,
123125
handler_class=AcademyHandler,
124126
response_builder_class=LearnerAcademyResponseBuilder,
127+
context_kwargs={'initialize_secured_algolia_api_keys': False},
125128
)
126129
return Response(response_data, status=status_code)
127130

@@ -152,5 +155,6 @@ def skills_quiz(self, request, *args, **kwargs):
152155
request=request,
153156
handler_class=SkillsQuizHandler,
154157
response_builder_class=LearnerSkillsQuizResponseBuilder,
158+
context_kwargs={'initialize_secured_algolia_api_keys': False},
155159
)
156160
return Response(response_data, status=status_code)

enterprise_access/apps/api_client/enterprise_catalog_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,13 @@ def secured_algolia_api_key_endpoint(self, enterprise_customer_uuid: str) -> str
155155
secured_algolia_api_key_path: str = f'enterprise-customer/{enterprise_customer_uuid}/secured-algolia-api-key/'
156156
return urljoin(self.api_base_url, secured_algolia_api_key_path)
157157

158-
def get_secured_algolia_api_key(self, enterprise_customer_uuid):
158+
def get_secured_algolia_api_key(self, enterprise_customer_uuid, catalog_uuids=None):
159159
"""
160160
Fetch secured algolia API keys
161161
162162
Arguments:
163163
enterprise_customer_uuid (uuid): UUID of the enterprise customer
164+
catalog_uuids (list[str], optional): Catalog UUIDs to restrict the secured key to.
164165
165166
Returns:
166167
200:
@@ -174,6 +175,10 @@ def get_secured_algolia_api_key(self, enterprise_customer_uuid):
174175
'user_message' (str): Message of corresponding error indicating a user oriented message
175176
'developer_message' (str): Message of corresponding error indicating an actionable developer message
176177
"""
177-
response = self.get(self.secured_algolia_api_key_endpoint(enterprise_customer_uuid))
178+
query_params = {'catalog_uuids': catalog_uuids} if catalog_uuids is not None else None
179+
response = self.get(
180+
self.secured_algolia_api_key_endpoint(enterprise_customer_uuid),
181+
params=query_params,
182+
)
178183
response.raise_for_status()
179184
return response.json()

enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ def test_contains_content_items(self, mock_oauth_client, mock_json):
4444

4545
assert contains_content_items
4646

47+
expected_endpoint = client.enterprise_catalog_endpoint + str(ent_uuid) + '/contains_content_items/'
4748
mock_oauth_client.return_value.get.assert_called_with(
48-
f'http://enterprise-catalog.example.com/api/v2/enterprise-catalogs/{ent_uuid}/contains_content_items/',
49+
expected_endpoint,
4950
params={'course_run_ids': ['AB+CD101']},
5051
)
5152

@@ -75,8 +76,9 @@ def test_catalog_content_metadata(self, mock_oauth_client):
7576
fetched_metadata = client.catalog_content_metadata(customer_uuid, content_keys)
7677

7778
self.assertEqual(fetched_metadata['results'], mock_response_json['results'])
79+
expected_endpoint = f'{client.enterprise_catalog_endpoint}{customer_uuid}/get_content_metadata/'
7880
mock_oauth_client.return_value.get.assert_called_with(
79-
f'http://enterprise-catalog.example.com/api/v2/enterprise-catalogs/{customer_uuid}/get_content_metadata/',
81+
expected_endpoint,
8082
params={
8183
'content_keys': content_keys,
8284
'traverse_pagination': True,
@@ -88,6 +90,8 @@ def test_catalog_content_metadata_raises_http_error(self, mock_oauth_client):
8890
content_keys = ['course+A', 'course+B']
8991
request_response = Response()
9092
request_response.status_code = 400
93+
request_response.url = 'http://test.example.com'
94+
request_response.raise_for_status = mock.Mock(side_effect=HTTPError())
9195

9296
mock_oauth_client.return_value.get.return_value = request_response
9397

@@ -97,14 +101,40 @@ def test_catalog_content_metadata_raises_http_error(self, mock_oauth_client):
97101
with self.assertRaises(HTTPError):
98102
client.catalog_content_metadata(customer_uuid, content_keys)
99103

104+
expected_endpoint = f'{client.enterprise_catalog_endpoint}{customer_uuid}/get_content_metadata/'
100105
mock_oauth_client.return_value.get.assert_called_with(
101-
f'http://enterprise-catalog.example.com/api/v2/enterprise-catalogs/{customer_uuid}/get_content_metadata/',
106+
expected_endpoint,
102107
params={
103108
'content_keys': content_keys,
104109
'traverse_pagination': True,
105110
},
106111
)
107112

113+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
114+
def test_catalog_content_metadata_raises_for_empty_keys_with_traverse_pagination(self, mock_oauth_client):
115+
"""
116+
catalog_content_metadata raises when content_keys is empty and traverse_pagination=True,
117+
to prevent fetching all metadata for an entire catalog (potentially thousands of records).
118+
"""
119+
catalog_uuid = uuid4()
120+
client = EnterpriseCatalogApiClient()
121+
122+
with self.assertRaises(Exception) as ctx:
123+
client.catalog_content_metadata(catalog_uuid, content_keys=[], traverse_pagination=True)
124+
125+
self.assertIn('Cannot request all metadata', str(ctx.exception))
126+
mock_oauth_client.return_value.get.assert_not_called()
127+
128+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
129+
def test_content_metadata_v2_raises_not_implemented(self, mock_oauth_client):
130+
"""EnterpriseCatalogApiClient.content_metadata (V2) is intentionally unimplemented."""
131+
client = EnterpriseCatalogApiClient()
132+
133+
with self.assertRaises(NotImplementedError):
134+
client.content_metadata('course+SomeKey')
135+
136+
mock_oauth_client.return_value.get.assert_not_called()
137+
108138
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
109139
def test_get_content_metadata_count(self, mock_oauth_client):
110140
mock_response_json = {
@@ -119,8 +149,9 @@ def test_get_content_metadata_count(self, mock_oauth_client):
119149
fetched_metadata = client.get_content_metadata_count(catalog_uuid)
120150

121151
self.assertEqual(fetched_metadata, mock_response_json['count'])
152+
expected_endpoint = client.enterprise_catalog_endpoint + str(catalog_uuid) + '/get_content_metadata/'
122153
mock_oauth_client.return_value.get.assert_called_with(
123-
f'http://enterprise-catalog.example.com/api/v2/enterprise-catalogs/{catalog_uuid}/get_content_metadata/',
154+
expected_endpoint,
124155
)
125156

126157

@@ -154,8 +185,9 @@ def test_content_metadata(self, mock_oauth_client, coerce_to_parent_course):
154185
expected_query_params_kwarg = {}
155186
if coerce_to_parent_course:
156187
expected_query_params_kwarg |= {'params': {'coerce_to_parent_course': True}}
188+
expected_endpoint = f'{client.content_metadata_endpoint}{content_key}'
157189
mock_oauth_client.return_value.get.assert_called_with(
158-
f'http://enterprise-catalog.example.com/api/v1/content-metadata/{content_key}',
190+
expected_endpoint,
159191
**expected_query_params_kwarg,
160192
)
161193

@@ -164,6 +196,8 @@ def test_content_metadata_raises_http_error(self, mock_oauth_client):
164196
content_key = 'course+A'
165197
request_response = Response()
166198
request_response.status_code = 400
199+
request_response.url = 'http://test.example.com'
200+
request_response.raise_for_status = mock.Mock(side_effect=HTTPError())
167201

168202
mock_oauth_client.return_value.get.return_value = request_response
169203

@@ -172,8 +206,9 @@ def test_content_metadata_raises_http_error(self, mock_oauth_client):
172206
with self.assertRaises(HTTPError):
173207
client.content_metadata(content_key)
174208

209+
expected_endpoint = f'{client.content_metadata_endpoint}{content_key}'
175210
mock_oauth_client.return_value.get.assert_called_with(
176-
f'http://enterprise-catalog.example.com/api/v1/content-metadata/{content_key}',
211+
expected_endpoint,
177212
)
178213

179214

@@ -200,11 +235,7 @@ def setUp(self):
200235
)
201236
@ddt.unpack
202237
def test_secured_algolia_api_key_endpoint(self, enterprise_customer_uuid):
203-
expected_url = (
204-
f'http://enterprise-catalog.example.com/api/v1'
205-
f'/enterprise-customer/{enterprise_customer_uuid}/secured-algolia-api-key/'
206-
)
207-
request = self.factory.get(expected_url)
238+
request = self.factory.get('http://test.example.com')
208239
request.headers = {
209240
"Authorization": 'test-auth',
210241
self.request_id_key: 'test-request-id'
@@ -223,16 +254,16 @@ def test_secured_algolia_api_key_endpoint(self, enterprise_customer_uuid):
223254
secured_algolia_api_key_url = client.secured_algolia_api_key_endpoint(
224255
enterprise_customer_uuid=enterprise_customer_uuid
225256
)
257+
expected_url = (
258+
f'{client.api_base_url}'
259+
f'enterprise-customer/{enterprise_customer_uuid}/secured-algolia-api-key/'
260+
)
226261
self.assertEqual(secured_algolia_api_key_url, expected_url)
227262

228263
@mock.patch('requests.Session.send')
229264
@mock.patch('crum.get_current_request')
230265
def test_secured_algolia_api_key(self, mock_crum_get_current_request, mock_send):
231-
expected_url = (
232-
f'http://enterprise-catalog.example.com/api/v1'
233-
f'/enterprise-customer/{self.mock_enterprise_customer_uuid}/secured-algolia-api-key/'
234-
)
235-
request = self.factory.get(expected_url)
266+
request = self.factory.get('http://test.example.com')
236267
request.headers = {
237268
"Authorization": 'test-auth',
238269
self.request_id_key: 'test-request-id'
@@ -264,6 +295,10 @@ def test_secured_algolia_api_key(self, mock_crum_get_current_request, mock_send)
264295
prepared_request = mock_send.call_args[0][0]
265296

266297
# Assert base request URL/method is correct
298+
expected_url = (
299+
f'{client.api_base_url}'
300+
f'enterprise-customer/{self.mock_enterprise_customer_uuid}/secured-algolia-api-key/'
301+
)
267302
parsed_url = urlparse(prepared_request.url)
268303
self.assertEqual(f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}", expected_url)
269304
self.assertEqual(prepared_request.method, 'GET')
@@ -274,3 +309,41 @@ def test_secured_algolia_api_key(self, mock_crum_get_current_request, mock_send)
274309

275310
# Assert the response is as expected
276311
self.assertEqual(result, expected_result)
312+
313+
@mock.patch('requests.Session.send')
314+
@mock.patch('crum.get_current_request')
315+
def test_secured_algolia_api_key_with_catalog_scope(self, mock_crum_get_current_request, mock_send):
316+
request = self.factory.get('http://test.example.com')
317+
request.headers = {
318+
"Authorization": 'test-auth',
319+
self.request_id_key: 'test-request-id'
320+
}
321+
request.user = self.user
322+
323+
mock_crum_get_current_request.return_value = request
324+
mock_response = mock.Mock()
325+
mock_response.status_code = 200
326+
mock_response.json.return_value = {
327+
'algolia': {
328+
'secured_api_key': 'key',
329+
'valid_until': _days_from_now(1, DATE_FORMAT_ISO_8601),
330+
},
331+
'catalog_uuids_to_catalog_query_uuids': {},
332+
}
333+
mock_send.return_value = mock_response
334+
335+
client = EnterpriseCatalogUserV1ApiClient(request)
336+
client.get_secured_algolia_api_key(
337+
enterprise_customer_uuid=self.mock_enterprise_customer_uuid,
338+
catalog_uuids=['cat-a', 'cat-b'],
339+
)
340+
prepared_request = mock_send.call_args[0][0]
341+
parsed_url = urlparse(prepared_request.url)
342+
343+
expected_url = (
344+
f'{client.api_base_url}'
345+
f'enterprise-customer/{self.mock_enterprise_customer_uuid}/secured-algolia-api-key/'
346+
)
347+
self.assertEqual(f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}", expected_url)
348+
self.assertIn('catalog_uuids=cat-a', parsed_url.query)
349+
self.assertIn('catalog_uuids=cat-b', parsed_url.query)

0 commit comments

Comments
 (0)