Skip to content

Commit 165fe7a

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

File tree

6 files changed

+355
-1
lines changed

6 files changed

+355
-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: 148 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
@@ -84,6 +85,7 @@
8485
logger = logging.getLogger(__name__)
8586

8687

88+
8789
class SubsidyRequestViewSet(UserDetailsFromJwtMixin, viewsets.ModelViewSet):
8890
"""
8991
Base Viewset for subsidy requests.
@@ -734,6 +736,17 @@ def decline(self, *args, **kwargs):
734736
summary='Approve a learner credit request.',
735737
request=serializers.LearnerCreditRequestApproveRequestSerializer,
736738
),
739+
bulk_approve=extend_schema(
740+
tags=['Learner Credit Requests'],
741+
summary='Bulk approve learner credit requests.',
742+
description=(
743+
'Bulk approve learner credit requests. Supports two modes:\n'
744+
'1. Specific UUID approval: provide subsidy_request_uuids\n'
745+
'2. Approve all: set approve_all=True (optionally with query filters)\n\n'
746+
'Response contains categorized results with uuid, state, and detail for each request.'
747+
),
748+
request=serializers.LearnerCreditRequestBulkApproveRequestSerializer,
749+
),
737750
overview=extend_schema(
738751
tags=['Learner Credit Requests'],
739752
summary='Learner credit request overview.',
@@ -1022,6 +1035,141 @@ def approve(self, request, *args, **kwargs):
10221035
lc_request_action.save()
10231036
return Response({"detail": error_msg}, exc.status_code)
10241037

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

0 commit comments

Comments
 (0)