Skip to content

Commit 488681e

Browse files
committed
feat: added bulk approval endpoint for B&R
1 parent 952b2f9 commit 488681e

File tree

6 files changed

+362
-1
lines changed

6 files changed

+362
-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 will be not_found, 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: 155 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
@@ -83,6 +84,9 @@
8384

8485
logger = logging.getLogger(__name__)
8586

87+
# Maximum number of requests that can be approved in a single bulk approve operation
88+
BULK_APPROVE_MAX_REQUESTS = 500
89+
8690

8791
class SubsidyRequestViewSet(UserDetailsFromJwtMixin, viewsets.ModelViewSet):
8892
"""
@@ -734,6 +738,17 @@ def decline(self, *args, **kwargs):
734738
summary='Approve a learner credit request.',
735739
request=serializers.LearnerCreditRequestApproveRequestSerializer,
736740
),
741+
bulk_approve=extend_schema(
742+
tags=['Learner Credit Requests'],
743+
summary='Bulk approve learner credit requests.',
744+
description=(
745+
'Bulk approve learner credit requests. Supports two modes:\n'
746+
'1. Specific UUID approval: provide subsidy_request_uuids\n'
747+
'2. Approve all: set approve_all=True (optionally with query filters)\n\n'
748+
'Response contains categorized results with uuid, state, and detail for each request.'
749+
),
750+
request=serializers.LearnerCreditRequestBulkApproveRequestSerializer,
751+
),
737752
overview=extend_schema(
738753
tags=['Learner Credit Requests'],
739754
summary='Learner credit request overview.',
@@ -1022,6 +1037,146 @@ def approve(self, request, *args, **kwargs):
10221037
lc_request_action.save()
10231038
return Response({"detail": error_msg}, exc.status_code)
10241039

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

0 commit comments

Comments
 (0)