Skip to content

Commit 3374869

Browse files
committed
feat: added bulk approval endpoint for B&R
1 parent 9796b25 commit 3374869

File tree

6 files changed

+354
-1
lines changed

6 files changed

+354
-1
lines changed

enterprise_access/apps/api/serializers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from .subsidy_requests import (
5050
CouponCodeRequestSerializer,
5151
LearnerCreditRequestApproveRequestSerializer,
52+
LearnerCreditRequestBulkApproveRequestSerializer,
5253
LearnerCreditRequestCancelSerializer,
5354
LearnerCreditRequestDeclineSerializer,
5455
LearnerCreditRequestRemindSerializer,

enterprise_access/apps/api/serializers/subsidy_requests.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,73 @@ def update(self, instance, validated_data):
309309
raise NotImplementedError("This serializer is for validation only")
310310

311311

312+
# pylint: disable=abstract-method
313+
class LearnerCreditRequestBulkApproveRequestSerializer(
314+
serializers.Serializer
315+
):
316+
"""
317+
Request Serializer to validate learner-credit bulk ``approve`` endpoint POST data.
318+
319+
For view: LearnerCreditRequestViewSet.bulk_approve
320+
321+
Supports two modes:
322+
1. Specific UUID approval: provide subsidy_request_uuids
323+
2. Approve all: set approve_all=True (optionally with query filters)
324+
"""
325+
326+
policy_uuid = serializers.UUIDField(
327+
required=True,
328+
help_text="The UUID of the policy to which the requests belong.",
329+
)
330+
enterprise_customer_uuid = serializers.UUIDField(
331+
required=True,
332+
help_text="The UUID of the Enterprise Customer.",
333+
)
334+
approve_all = serializers.BooleanField(
335+
default=False,
336+
help_text="If True, approve all REQUESTED state requests for the policy. "
337+
"Cannot be used with subsidy_request_uuids.",
338+
)
339+
subsidy_request_uuids = serializers.ListField(
340+
child=serializers.UUIDField(),
341+
required=False,
342+
allow_empty=False,
343+
help_text="List of LearnerCreditRequest UUIDs to approve. Required when approve_all=False.",
344+
)
345+
346+
# pylint: disable=arguments-renamed
347+
def validate(self, data):
348+
"""
349+
Validate that either approve_all=True or subsidy_request_uuids is provided, but not both.
350+
"""
351+
approve_all = data.get("approve_all", False)
352+
subsidy_request_uuids = data.get("subsidy_request_uuids")
353+
354+
if not approve_all and not subsidy_request_uuids:
355+
raise serializers.ValidationError(
356+
"Either provide subsidy_request_uuids or set approve_all=True"
357+
)
358+
359+
if approve_all and subsidy_request_uuids:
360+
raise serializers.ValidationError(
361+
"Cannot specify both approve_all=True and subsidy_request_uuids"
362+
)
363+
364+
return data
365+
366+
def create(self, validated_data):
367+
"""
368+
Not implemented - this serializer is for validation only
369+
"""
370+
raise NotImplementedError("This serializer is for validation only")
371+
372+
def update(self, instance, validated_data):
373+
"""
374+
Not implemented - this serializer is for validation only
375+
"""
376+
raise NotImplementedError("This serializer is for validation only")
377+
378+
312379
# pylint: disable=abstract-method
313380
class LearnerCreditRequestCancelSerializer(serializers.Serializer):
314381
"""

enterprise_access/apps/api/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,21 @@ def get_or_fetch_enterprise_uuid_for_bff_request(request):
116116

117117
# Could not derive enterprise_customer_uuid for the BFF request.
118118
return None
119+
120+
121+
def add_bulk_approve_operation_result(
122+
results_dict, category, uuid, state, detail
123+
):
124+
"""
125+
Add a standardized result entry to a bulk operation results dictionary.
126+
127+
Args:
128+
results_dict (dict): Dictionary containing categorized results
129+
category (str): Result category (e.g., 'approved', 'failed', 'skipped', 'not_found')
130+
uuid (str): UUID of the request being processed
131+
state (str|None): Current state of the request, or None if not applicable
132+
detail (str): Descriptive message about the operation result
133+
"""
134+
results_dict[category].append(
135+
{"uuid": str(uuid), "state": state, "detail": detail}
136+
)

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

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
REASON_POLICY_SPEND_LIMIT_REACHED,
3232
REASON_SUBSIDY_EXPIRED
3333
)
34-
from enterprise_access.apps.subsidy_access_policy.exceptions import SubsidyAccessPolicyLockAttemptFailed
34+
from enterprise_access.apps.subsidy_access_policy.exceptions import (
35+
SubisidyAccessPolicyRequestApprovalError,
36+
SubsidyAccessPolicyLockAttemptFailed
37+
)
3538
from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy
3639
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
3740
PerLearnerSpendCapLearnerCreditAccessPolicyFactory
@@ -2879,6 +2882,94 @@ def test_cancel_success(self, mock_cancel_assignments):
28792882
).first()
28802883
assert success_action is not None
28812884

2885+
@mock.patch(
2886+
"enterprise_access.apps.api.v1.views.browse_and_request.approve_learner_credit_request_via_policy"
2887+
)
2888+
def test_bulk_approve_mixed_success(self, mock_approve):
2889+
"""
2890+
Test bulk approve returns partial success without failing the whole request.
2891+
"""
2892+
# Set admin context for the correct enterprise
2893+
self.set_jwt_cookie(
2894+
[
2895+
{
2896+
"system_wide_role": SYSTEM_ENTERPRISE_ADMIN_ROLE,
2897+
"context": str(self.enterprise_customer_uuid_1),
2898+
}
2899+
]
2900+
)
2901+
2902+
# One request will approve, one will fail, one skipped
2903+
requested_ok = self.user_request_1 # requested, will approve
2904+
requested_fail = LearnerCreditRequestFactory(
2905+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2906+
user=self.user,
2907+
learner_credit_request_config=self.learner_credit_config,
2908+
course_price=1200,
2909+
state=SubsidyRequestStates.REQUESTED,
2910+
assignment=None,
2911+
)
2912+
skipped_req = LearnerCreditRequestFactory(
2913+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2914+
learner_credit_request_config=self.learner_credit_config,
2915+
state=SubsidyRequestStates.APPROVED,
2916+
)
2917+
2918+
# Configure approve side effects
2919+
def approve_side_effect(
2920+
_policy_uuid,
2921+
content_key,
2922+
content_price_cents,
2923+
learner_email,
2924+
lms_user_id,
2925+
):
2926+
if (str(requested_fail.user.lms_user_id) == str(lms_user_id) and
2927+
content_price_cents == requested_fail.course_price):
2928+
raise SubisidyAccessPolicyRequestApprovalError(
2929+
"policy validation failed", 422
2930+
)
2931+
# Return a basic assignment via factory
2932+
return LearnerContentAssignmentFactory(
2933+
assignment_configuration=self.assignment_config,
2934+
learner_email=learner_email,
2935+
lms_user_id=lms_user_id,
2936+
content_key=content_key,
2937+
content_quantity=-abs(content_price_cents),
2938+
state="allocated",
2939+
)
2940+
2941+
mock_approve.side_effect = approve_side_effect
2942+
2943+
url = reverse("api:v1:learner-credit-requests-bulk-approve")
2944+
payload = {
2945+
"enterprise_customer_uuid": str(self.enterprise_customer_uuid_1),
2946+
"policy_uuid": str(self.policy.uuid),
2947+
"subsidy_request_uuids": [
2948+
str(requested_ok.uuid),
2949+
str(requested_fail.uuid),
2950+
str(skipped_req.uuid),
2951+
str(uuid4()), # not found
2952+
],
2953+
}
2954+
response = self.client.post(url, payload)
2955+
assert response.status_code == status.HTTP_200_OK
2956+
2957+
data = response.json()
2958+
assert len(data["approved"]) == 1
2959+
assert len(data["failed"]) == 1
2960+
assert len(data["skipped"]) == 1
2961+
2962+
requested_ok.refresh_from_db()
2963+
requested_fail.refresh_from_db()
2964+
skipped_req.refresh_from_db()
2965+
2966+
assert requested_ok.state == SubsidyRequestStates.APPROVED
2967+
assert requested_fail.state in [
2968+
SubsidyRequestStates.REQUESTED,
2969+
SubsidyRequestStates.ERROR,
2970+
]
2971+
assert skipped_req.state == SubsidyRequestStates.APPROVED
2972+
28822973
@mock.patch('enterprise_access.apps.content_assignments.api.cancel_assignments')
28832974
def test_cancel_failed_assignment_cancellation(self, mock_cancel_assignments):
28842975
"""

enterprise_access/apps/api/v1/views/browse_and_request.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
update_license_requests_after_assignments_task
3939
)
4040
from enterprise_access.apps.api.utils import (
41+
add_bulk_approve_operation_result,
4142
get_enterprise_uuid_from_query_params,
4243
get_enterprise_uuid_from_request_data,
4344
validate_uuid
@@ -734,6 +735,17 @@ def decline(self, *args, **kwargs):
734735
summary='Approve a learner credit request.',
735736
request=serializers.LearnerCreditRequestApproveRequestSerializer,
736737
),
738+
bulk_approve=extend_schema(
739+
tags=['Learner Credit Requests'],
740+
summary='Bulk approve learner credit requests.',
741+
description=(
742+
'Bulk approve learner credit requests. Supports two modes:\n'
743+
'1. Specific UUID approval: provide subsidy_request_uuids\n'
744+
'2. Approve all: set approve_all=True (optionally with query filters)\n\n'
745+
'Response contains categorized results with uuid, state, and detail for each request.'
746+
),
747+
request=serializers.LearnerCreditRequestBulkApproveRequestSerializer,
748+
),
737749
overview=extend_schema(
738750
tags=['Learner Credit Requests'],
739751
summary='Learner credit request overview.',
@@ -1022,6 +1034,141 @@ def approve(self, request, *args, **kwargs):
10221034
lc_request_action.save()
10231035
return Response({"detail": error_msg}, exc.status_code)
10241036

1037+
@permission_required(
1038+
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
1039+
fn=get_enterprise_uuid_from_request_data,
1040+
)
1041+
@action(detail=False, url_path="bulk-approve", methods=["post"])
1042+
def bulk_approve(self, request, *args, **kwargs):
1043+
"""
1044+
Bulk approve learner credit requests.
1045+
1046+
Supports two modes:
1047+
1. Specific UUID approval: provide subsidy_request_uuids
1048+
2. Approve all: set approve_all=True (optionally with query filters)
1049+
1050+
Processes each request independently and returns a summary with
1051+
approved and failed items. Partial success is allowed.
1052+
"""
1053+
serializer = (
1054+
serializers.LearnerCreditRequestBulkApproveRequestSerializer(
1055+
data=request.data
1056+
)
1057+
)
1058+
serializer.is_valid(raise_exception=True)
1059+
policy_uuid = serializer.validated_data["policy_uuid"]
1060+
approve_all = serializer.validated_data.get("approve_all", False)
1061+
1062+
if approve_all:
1063+
base_queryset = LearnerCreditRequest.objects.filter(
1064+
state=SubsidyRequestStates.REQUESTED,
1065+
learner_credit_request_config__learner_credit_config__uuid=policy_uuid,
1066+
).select_related("user")
1067+
1068+
requests_to_process = self.filter_queryset(base_queryset)
1069+
1070+
requests_by_uuid = {
1071+
str(req.uuid): req for req in requests_to_process
1072+
}
1073+
else:
1074+
subsidy_request_uuids = serializer.validated_data["subsidy_request_uuids"]
1075+
requests_by_uuid = {
1076+
str(req.uuid): req
1077+
for req in LearnerCreditRequest.objects.select_related(
1078+
"user"
1079+
).filter(uuid__in=subsidy_request_uuids)
1080+
}
1081+
1082+
results = {"approved": [], "failed": [], "not_found": [], "skipped": []}
1083+
1084+
approved_requests = []
1085+
successful_request_data = []
1086+
1087+
for uuid_val, lc_request in requests_by_uuid.items():
1088+
if (not approve_all and lc_request.state != SubsidyRequestStates.REQUESTED):
1089+
add_bulk_approve_operation_result(
1090+
results, "skipped", uuid_val, lc_request.state,
1091+
f"Request already in {lc_request.state} state"
1092+
)
1093+
continue
1094+
1095+
learner_email = lc_request.user.email
1096+
content_key = lc_request.course_id
1097+
content_price_cents = lc_request.course_price
1098+
1099+
lc_request_action = LearnerCreditRequestActions.create_action(
1100+
learner_credit_request=lc_request,
1101+
recent_action=get_action_choice(
1102+
SubsidyRequestStates.APPROVED
1103+
),
1104+
status=get_user_message_choice(SubsidyRequestStates.APPROVED),
1105+
)
1106+
1107+
try:
1108+
with transaction.atomic():
1109+
assignment = approve_learner_credit_request_via_policy(
1110+
policy_uuid,
1111+
content_key,
1112+
content_price_cents,
1113+
learner_email,
1114+
lc_request.user.lms_user_id,
1115+
)
1116+
1117+
# Prepare for bulk processing instead of individual saves
1118+
lc_request.assignment = assignment
1119+
1120+
approved_requests.append(lc_request)
1121+
successful_request_data.append({
1122+
'uuid': uuid_val,
1123+
'state': SubsidyRequestStates.APPROVED,
1124+
'message': "Successfully approved",
1125+
'assignment_uuid': assignment.uuid
1126+
})
1127+
1128+
except SubisidyAccessPolicyRequestApprovalError as exc:
1129+
error_msg = (
1130+
f"[LC REQUEST BULK APPROVAL] Failed to approve learner credit request "
1131+
f"with UUID {uuid_val}. Reason: {exc.message}."
1132+
)
1133+
logger.exception(error_msg)
1134+
# Update action with error
1135+
lc_request_action.status = get_user_message_choice(
1136+
SubsidyRequestStates.REQUESTED
1137+
)
1138+
lc_request_action.error_reason = get_error_reason_choice(
1139+
LearnerCreditRequestActionErrorReasons.FAILED_APPROVAL
1140+
)
1141+
lc_request_action.traceback = format_traceback(exc)
1142+
lc_request_action.save()
1143+
add_bulk_approve_operation_result(results, "failed", uuid_val, lc_request.state, exc.message)
1144+
1145+
if approved_requests:
1146+
try:
1147+
with transaction.atomic():
1148+
LearnerCreditRequest.bulk_approve_requests(approved_requests, request.user)
1149+
1150+
# Send notifications and record results
1151+
for request_data in successful_request_data:
1152+
send_learner_credit_bnr_request_approve_task.delay(request_data['assignment_uuid'])
1153+
add_bulk_approve_operation_result(
1154+
results,
1155+
"approved",
1156+
request_data['uuid'],
1157+
request_data['state'],
1158+
request_data['message'],
1159+
)
1160+
1161+
except (ValidationError, IntegrityError, DatabaseError) as exc:
1162+
error_msg = f"[LC REQUEST BULK APPROVAL] Bulk update failed: {exc}"
1163+
logger.exception(error_msg)
1164+
for request_data in successful_request_data:
1165+
add_bulk_approve_operation_result(
1166+
results, "failed", request_data['uuid'],
1167+
SubsidyRequestStates.REQUESTED, str(exc)
1168+
)
1169+
1170+
return Response(results, status=status.HTTP_200_OK)
1171+
10251172
@permission_required(
10261173
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
10271174
fn=get_enterprise_uuid_from_request_data,

0 commit comments

Comments
 (0)