Skip to content

Commit b3a62fd

Browse files
Merge branch 'main' into ismail-11468
2 parents a6f76d3 + 4a4f726 commit b3a62fd

13 files changed

Lines changed: 593 additions & 59 deletions

File tree

enterprise_access/apps/api/serializers/subsidy_requests.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -222,67 +222,97 @@ class Meta:
222222

223223
class LearnerCreditRequestDeclineSerializer(serializers.Serializer):
224224
"""
225-
Serializer for declining a learner credit request.
225+
Serializer for declining one or more learner credit requests.
226+
227+
Accepts either ``subsidy_request_uuid`` (single UUID, legacy) or
228+
``subsidy_request_uuids`` (list, mirrors the ``approve`` endpoint contract).
229+
Exactly one of the two MUST be provided.
226230
"""
227231

228232
subsidy_request_uuid = serializers.UUIDField(
229-
required=True, help_text="UUID of the learner credit request to decline"
233+
required=False, help_text="UUID of a single learner credit request to decline."
234+
)
235+
subsidy_request_uuids = serializers.ListField(
236+
child=serializers.UUIDField(),
237+
required=False,
238+
allow_empty=False,
239+
help_text="List of LearnerCreditRequest UUIDs to decline.",
240+
)
241+
policy_uuid = serializers.UUIDField(
242+
required=False,
243+
help_text=(
244+
"The UUID of the SubsidyAccessPolicy associated with the requests. "
245+
"Optional; included for symmetry with the approve endpoint contract."
246+
),
230247
)
231248
send_notification = serializers.BooleanField(
232249
default=False, help_text="Whether to send decline notification email to the learner"
233250
)
234251
disassociate_from_org = serializers.BooleanField(
235252
default=False, help_text="Whether to unlink the user from the enterprise organization"
236253
)
237-
# Add a new optional field 'decline_reason' to capture reason for decline
238-
# Default value is None (null) if not provided
239254
decline_reason = serializers.CharField(
240-
required=False, # makes the field optional
241-
allow_blank=True, # allows empty string ""
242-
allow_null=True, # allows null value
243-
help_text="Reason for declining"
255+
required=False,
256+
allow_blank=True,
257+
allow_null=True,
258+
help_text="Reason for declining",
244259
)
245260

246261
def __init__(self, *args, **kwargs):
247262
super().__init__(*args, **kwargs)
248-
self._learner_credit_request = None
263+
self._learner_credit_requests = []
249264

250-
def validate_subsidy_request_uuid(self, value):
251-
"""
252-
Validate that the subsidy request exists and can be declined.
253-
"""
254-
try:
255-
learner_credit_request = LearnerCreditRequest.objects.get(uuid=value)
256-
except LearnerCreditRequest.DoesNotExist as exc:
257-
raise serializers.ValidationError(f"Learner Credit Request with UUID {value} not found.") from exc
265+
def validate(self, attrs):
266+
single_uuid = attrs.get('subsidy_request_uuid')
267+
many_uuids = attrs.get('subsidy_request_uuids')
258268

259-
if learner_credit_request.state not in [SubsidyRequestStates.REQUESTED]:
269+
if single_uuid and many_uuids:
270+
raise serializers.ValidationError(
271+
"Provide either subsidy_request_uuid or subsidy_request_uuids, not both."
272+
)
273+
if not single_uuid and not many_uuids:
260274
raise serializers.ValidationError(
261-
f'Learner Credit Request with UUID {value} cannot be declined. '
262-
f'Current state: {learner_credit_request.state}'
275+
"Must provide subsidy_request_uuid or subsidy_request_uuids."
263276
)
264277

265-
# Store the fetched object for later use
266-
self._learner_credit_request = learner_credit_request
278+
uuids = [single_uuid] if single_uuid else many_uuids
279+
280+
learner_credit_requests = list(LearnerCreditRequest.objects.filter(uuid__in=uuids))
281+
found_uuids = {r.uuid for r in learner_credit_requests}
282+
missing = [str(u) for u in uuids if u not in found_uuids]
283+
if missing:
284+
raise serializers.ValidationError(
285+
f"Learner Credit Request(s) with UUID(s) {missing} not found."
286+
)
287+
288+
non_declinable = [
289+
f'{r.uuid} (state: {r.state})' for r in learner_credit_requests
290+
if r.state != SubsidyRequestStates.REQUESTED
291+
]
292+
if non_declinable:
293+
raise serializers.ValidationError(
294+
f"Learner Credit Request(s) cannot be declined: {non_declinable}"
295+
)
267296

268-
return value
297+
self._learner_credit_requests = learner_credit_requests
298+
return attrs
269299

270300
def get_learner_credit_request(self):
271301
"""
272-
Return the already-fetched LearnerCreditRequest object
302+
Legacy accessor; returns the single (or first) validated request.
273303
"""
274-
return self._learner_credit_request
304+
return self._learner_credit_requests[0] if self._learner_credit_requests else None
275305

276-
def create(self, validated_data):
306+
def get_learner_credit_requests(self):
277307
"""
278-
Not implemented - this serializer is for validation only
308+
Return all validated declinable LearnerCreditRequest objects.
279309
"""
310+
return self._learner_credit_requests
311+
312+
def create(self, validated_data):
280313
raise NotImplementedError("This serializer is for validation only")
281314

282315
def update(self, instance, validated_data):
283-
"""
284-
Not implemented - this serializer is for validation only
285-
"""
286316
raise NotImplementedError("This serializer is for validation only")
287317

288318

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

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from rest_framework import status
1414
from rest_framework.reverse import reverse
1515

16+
from enterprise_access.apps.api.serializers import LearnerCreditRequestDeclineSerializer
1617
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices
1718
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment
1819
from enterprise_access.apps.content_assignments.tests.factories import (
@@ -2145,6 +2146,258 @@ def test_decline_reason_saved(self, mock_get_enterprise_uuid):
21452146
assert self.user_request_1.reviewer == self.user
21462147
assert self.user_request_1.decline_reason == 'Request outside program scope'
21472148

2149+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2150+
def test_decline_with_uuid_list_success(self, mock_get_enterprise_uuid):
2151+
"""
2152+
Decline accepts a list of UUIDs (``subsidy_request_uuids``) mirroring approve.
2153+
"""
2154+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2155+
self.set_jwt_cookie([{
2156+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2157+
'context': str(self.enterprise_customer_uuid_1)
2158+
}])
2159+
2160+
url = reverse('api:v1:learner-credit-requests-decline')
2161+
uuids = [str(self.user_request_1.uuid), str(self.enterprise_request.uuid)]
2162+
data = {
2163+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2164+
'subsidy_request_uuids': uuids,
2165+
'policy_uuid': str(self.policy.uuid),
2166+
}
2167+
2168+
response = self.client.post(url, data)
2169+
2170+
assert response.status_code == status.HTTP_200_OK
2171+
assert set(response.json()['declined']) == set(uuids)
2172+
2173+
for request_obj in (self.user_request_1, self.enterprise_request):
2174+
request_obj.refresh_from_db()
2175+
assert request_obj.state == SubsidyRequestStates.DECLINED
2176+
assert request_obj.reviewer == self.user
2177+
2178+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2179+
def test_decline_requires_uuid_or_uuids(self, mock_get_enterprise_uuid):
2180+
"""
2181+
Decline returns 400 when neither ``subsidy_request_uuid`` nor ``subsidy_request_uuids`` is provided.
2182+
"""
2183+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2184+
self.set_jwt_cookie([{
2185+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2186+
'context': str(self.enterprise_customer_uuid_1)
2187+
}])
2188+
2189+
url = reverse('api:v1:learner-credit-requests-decline')
2190+
response = self.client.post(url, {'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1)})
2191+
2192+
assert response.status_code == status.HTTP_400_BAD_REQUEST
2193+
assert 'Must provide subsidy_request_uuid' in str(response.data)
2194+
2195+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2196+
def test_decline_rejects_both_uuid_and_uuids(self, mock_get_enterprise_uuid):
2197+
"""
2198+
Decline returns 400 when both single and list payloads are provided.
2199+
"""
2200+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2201+
self.set_jwt_cookie([{
2202+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2203+
'context': str(self.enterprise_customer_uuid_1)
2204+
}])
2205+
2206+
url = reverse('api:v1:learner-credit-requests-decline')
2207+
data = {
2208+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2209+
'subsidy_request_uuid': str(self.user_request_1.uuid),
2210+
'subsidy_request_uuids': [str(self.enterprise_request.uuid)],
2211+
}
2212+
response = self.client.post(url, data)
2213+
2214+
assert response.status_code == status.HTTP_400_BAD_REQUEST
2215+
assert 'not both' in str(response.data)
2216+
2217+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2218+
def test_decline_uuid_list_with_non_declinable_request(self, mock_get_enterprise_uuid):
2219+
"""
2220+
Decline returns 400 when any UUID in the list points at a non-declinable request.
2221+
"""
2222+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2223+
self.set_jwt_cookie([{
2224+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2225+
'context': str(self.enterprise_customer_uuid_1)
2226+
}])
2227+
2228+
self.enterprise_request.state = SubsidyRequestStates.APPROVED
2229+
self.enterprise_request.save()
2230+
2231+
url = reverse('api:v1:learner-credit-requests-decline')
2232+
data = {
2233+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2234+
'subsidy_request_uuids': [
2235+
str(self.user_request_1.uuid),
2236+
str(self.enterprise_request.uuid),
2237+
],
2238+
}
2239+
response = self.client.post(url, data)
2240+
2241+
assert response.status_code == status.HTTP_400_BAD_REQUEST
2242+
assert 'cannot be declined' in str(response.data)
2243+
2244+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2245+
def test_decline_uuid_list_with_unknown_uuid(self, mock_get_enterprise_uuid):
2246+
"""
2247+
Decline returns 400 when any UUID in the list does not exist.
2248+
"""
2249+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2250+
self.set_jwt_cookie([{
2251+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2252+
'context': str(self.enterprise_customer_uuid_1)
2253+
}])
2254+
2255+
url = reverse('api:v1:learner-credit-requests-decline')
2256+
data = {
2257+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2258+
'subsidy_request_uuids': [str(self.user_request_1.uuid), str(uuid4())],
2259+
}
2260+
response = self.client.post(url, data)
2261+
2262+
assert response.status_code == status.HTTP_400_BAD_REQUEST
2263+
assert 'not found' in str(response.data)
2264+
2265+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2266+
@mock.patch(BNR_VIEW_PATH + '.unlink_users_from_enterprise_task.delay')
2267+
def test_decline_uuid_list_with_disassociate_from_org(self, mock_unlink_task, mock_get_enterprise_uuid):
2268+
"""
2269+
Decline with ``disassociate_from_org=True`` unlinks each declined learner.
2270+
"""
2271+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2272+
self.set_jwt_cookie([{
2273+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2274+
'context': str(self.enterprise_customer_uuid_1)
2275+
}])
2276+
2277+
url = reverse('api:v1:learner-credit-requests-decline')
2278+
data = {
2279+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2280+
'subsidy_request_uuids': [
2281+
str(self.user_request_1.uuid),
2282+
str(self.enterprise_request.uuid),
2283+
],
2284+
'disassociate_from_org': True,
2285+
}
2286+
response = self.client.post(url, data)
2287+
2288+
assert response.status_code == status.HTTP_200_OK
2289+
mock_unlink_task.assert_called_once()
2290+
call_args = mock_unlink_task.call_args.args
2291+
assert call_args[0] == str(self.enterprise_customer_uuid_1)
2292+
assert set(call_args[1]) == {
2293+
self.user_request_1.user.lms_user_id,
2294+
self.enterprise_request.user.lms_user_id,
2295+
}
2296+
2297+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2298+
def test_decline_send_notification_true_logs_per_declined_request(self, mock_get_enterprise_uuid):
2299+
"""
2300+
Decline with ``send_notification=True`` emits a per-request log line for each
2301+
declined request (one logger.info call per UUID in the input list).
2302+
"""
2303+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2304+
self.set_jwt_cookie([{
2305+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2306+
'context': str(self.enterprise_customer_uuid_1)
2307+
}])
2308+
2309+
url = reverse('api:v1:learner-credit-requests-decline')
2310+
uuids = [str(self.user_request_1.uuid), str(self.enterprise_request.uuid)]
2311+
data = {
2312+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2313+
'subsidy_request_uuids': uuids,
2314+
'send_notification': True,
2315+
}
2316+
2317+
with self.assertLogs(BNR_VIEW_PATH, level='INFO') as captured:
2318+
response = self.client.post(url, data)
2319+
2320+
assert response.status_code == status.HTTP_200_OK
2321+
assert set(response.json()['declined']) == set(uuids)
2322+
2323+
per_request_log_matches = [
2324+
record for record in captured.output
2325+
if 'Decline notification already queued by api layer for request' in record
2326+
]
2327+
assert len(per_request_log_matches) == 2
2328+
for uuid_value in uuids:
2329+
assert any(uuid_value in record for record in per_request_log_matches)
2330+
2331+
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
2332+
@mock.patch(BNR_VIEW_PATH + '.subsidy_request_api.decline_learner_credit_requests')
2333+
def test_decline_returns_422_when_api_reports_non_declinable(
2334+
self, mock_decline_api, mock_get_enterprise_uuid,
2335+
):
2336+
"""
2337+
Decline returns 422 if the backend API reports any request as non-declinable
2338+
(e.g. a race where state changes between serializer validation and the
2339+
API call). The response surfaces both declined and non_declinable lists.
2340+
"""
2341+
mock_get_enterprise_uuid.return_value = str(self.enterprise_customer_uuid_1)
2342+
self.set_jwt_cookie([{
2343+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2344+
'context': str(self.enterprise_customer_uuid_1)
2345+
}])
2346+
2347+
mock_decline_api.return_value = {
2348+
'declined': [self.user_request_1],
2349+
'non_declinable': [self.enterprise_request],
2350+
}
2351+
2352+
url = reverse('api:v1:learner-credit-requests-decline')
2353+
data = {
2354+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2355+
'subsidy_request_uuids': [
2356+
str(self.user_request_1.uuid),
2357+
str(self.enterprise_request.uuid),
2358+
],
2359+
}
2360+
response = self.client.post(url, data)
2361+
2362+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
2363+
body = response.json()
2364+
assert body['declined'] == [str(self.user_request_1.uuid)]
2365+
assert body['non_declinable'] == [str(self.enterprise_request.uuid)]
2366+
2367+
def test_decline_serializer_legacy_accessor_returns_first_or_none(self):
2368+
"""
2369+
``LearnerCreditRequestDeclineSerializer.get_learner_credit_request`` (legacy
2370+
singular accessor) returns ``None`` before validation and the first validated
2371+
request after a successful ``is_valid()``.
2372+
"""
2373+
serializer = LearnerCreditRequestDeclineSerializer(
2374+
data={'subsidy_request_uuid': str(self.user_request_1.uuid)},
2375+
)
2376+
# Before validation: no underlying requests have been resolved.
2377+
assert serializer.get_learner_credit_request() is None
2378+
assert serializer.get_learner_credit_requests() == []
2379+
2380+
assert serializer.is_valid(), serializer.errors
2381+
first = serializer.get_learner_credit_request()
2382+
assert first is not None
2383+
assert first.uuid == self.user_request_1.uuid
2384+
2385+
def test_decline_serializer_create_and_update_raise_not_implemented(self):
2386+
"""
2387+
``LearnerCreditRequestDeclineSerializer`` is validation-only; ``create`` and
2388+
``update`` must raise ``NotImplementedError`` so callers cannot misuse it as
2389+
a ModelSerializer.
2390+
"""
2391+
serializer = LearnerCreditRequestDeclineSerializer(
2392+
data={'subsidy_request_uuid': str(self.user_request_1.uuid)},
2393+
)
2394+
assert serializer.is_valid(), serializer.errors
2395+
2396+
with self.assertRaises(NotImplementedError):
2397+
serializer.create(serializer.validated_data)
2398+
with self.assertRaises(NotImplementedError):
2399+
serializer.update(self.user_request_1, serializer.validated_data)
2400+
21482401
@mock.patch('enterprise_access.apps.api.v1.views.browse_and_request.get_enterprise_uuid_from_request_data')
21492402
@mock.patch(
21502403
'enterprise_access.apps.api_client.enterprise_catalog_client.'

0 commit comments

Comments
 (0)