Skip to content

Commit 4ed17e3

Browse files
committed
feat: added bulk approval endpoint for B&R
1 parent 71b1c50 commit 4ed17e3

File tree

4 files changed

+238
-1
lines changed

4 files changed

+238
-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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,40 @@ 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(serializers.Serializer):
314+
"""
315+
Request Serializer to validate learner-credit bulk ``approve`` endpoint POST data.
316+
317+
For view: LearnerCreditRequestViewSet.bulk_approve
318+
"""
319+
policy_uuid = serializers.UUIDField(
320+
required=True,
321+
help_text='The UUID of the policy to which the requests belong.',
322+
)
323+
enterprise_customer_uuid = serializers.UUIDField(
324+
required=True,
325+
help_text='The UUID of the Enterprise Customer.',
326+
)
327+
subsidy_request_uuids = serializers.ListField(
328+
child=serializers.UUIDField(),
329+
allow_empty=False,
330+
help_text='List of LearnerCreditRequest UUIDs to approve.',
331+
)
332+
333+
def create(self, validated_data):
334+
"""
335+
Not implemented - this serializer is for validation only
336+
"""
337+
raise NotImplementedError("This serializer is for validation only")
338+
339+
def update(self, instance, validated_data):
340+
"""
341+
Not implemented - this serializer is for validation only
342+
"""
343+
raise NotImplementedError("This serializer is for validation only")
344+
345+
312346
# pylint: disable=abstract-method
313347
class LearnerCreditRequestCancelSerializer(serializers.Serializer):
314348
"""

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

Lines changed: 93 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,95 @@ 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["not_found"]) == 1
2961+
assert len(data["skipped"]) == 1
2962+
2963+
requested_ok.refresh_from_db()
2964+
requested_fail.refresh_from_db()
2965+
skipped_req.refresh_from_db()
2966+
2967+
assert requested_ok.state == SubsidyRequestStates.APPROVED
2968+
assert requested_fail.state in [
2969+
SubsidyRequestStates.REQUESTED,
2970+
SubsidyRequestStates.ERROR,
2971+
]
2972+
assert skipped_req.state == SubsidyRequestStates.APPROVED
2973+
28822974
@mock.patch('enterprise_access.apps.content_assignments.api.cancel_assignments')
28832975
def test_cancel_failed_assignment_cancellation(self, mock_cancel_assignments):
28842976
"""

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,11 @@ def decline(self, *args, **kwargs):
734734
summary='Approve a learner credit request.',
735735
request=serializers.LearnerCreditRequestApproveRequestSerializer,
736736
),
737+
bulk_approve=extend_schema(
738+
tags=['Learner Credit Requests'],
739+
summary='Bulk approve learner credit requests.',
740+
request=serializers.LearnerCreditRequestBulkApproveRequestSerializer,
741+
),
737742
overview=extend_schema(
738743
tags=['Learner Credit Requests'],
739744
summary='Learner credit request overview.',
@@ -1021,6 +1026,111 @@ def approve(self, request, *args, **kwargs):
10211026
lc_request_action.save()
10221027
return Response({"detail": error_msg}, exc.status_code)
10231028

1029+
@permission_required(
1030+
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
1031+
fn=get_enterprise_uuid_from_request_data,
1032+
)
1033+
@action(detail=False, url_path="bulk-approve", methods=["post"])
1034+
def bulk_approve(self, request, *args, **kwargs):
1035+
"""
1036+
Bulk approve learner credit requests.
1037+
1038+
Processes each request independently and returns a summary with
1039+
approved and failed items. Partial success is allowed.
1040+
"""
1041+
serializer = (
1042+
serializers.LearnerCreditRequestBulkApproveRequestSerializer(
1043+
data=request.data
1044+
)
1045+
)
1046+
serializer.is_valid(raise_exception=True)
1047+
policy_uuid = serializer.validated_data["policy_uuid"]
1048+
subsidy_request_uuids = serializer.validated_data[
1049+
"subsidy_request_uuids"
1050+
]
1051+
1052+
# Fetch all requests upfront to limit queries
1053+
requests_by_uuid = {
1054+
str(req.uuid): req
1055+
for req in LearnerCreditRequest.objects.select_related(
1056+
"user"
1057+
).filter(uuid__in=subsidy_request_uuids)
1058+
}
1059+
1060+
results = {
1061+
"approved": [],
1062+
"failed": [],
1063+
"not_found": [],
1064+
"skipped": [],
1065+
}
1066+
1067+
# Validate presence
1068+
for uuid_val in map(str, subsidy_request_uuids):
1069+
if uuid_val not in requests_by_uuid:
1070+
results["not_found"].append(
1071+
{"uuid": uuid_val, "detail": "Request not found"}
1072+
)
1073+
1074+
for uuid_val, lc_request in requests_by_uuid.items():
1075+
if lc_request.state not in [SubsidyRequestStates.REQUESTED]:
1076+
results["skipped"].append(
1077+
{"uuid": uuid_val, "state": lc_request.state}
1078+
)
1079+
continue
1080+
1081+
learner_email = lc_request.user.email
1082+
content_key = lc_request.course_id
1083+
content_price_cents = lc_request.course_price
1084+
1085+
lc_request_action = LearnerCreditRequestActions.create_action(
1086+
learner_credit_request=lc_request,
1087+
recent_action=get_action_choice(
1088+
SubsidyRequestStates.APPROVED
1089+
),
1090+
status=get_user_message_choice(SubsidyRequestStates.APPROVED),
1091+
)
1092+
1093+
try:
1094+
with transaction.atomic():
1095+
assignment = approve_learner_credit_request_via_policy(
1096+
policy_uuid,
1097+
content_key,
1098+
content_price_cents,
1099+
learner_email,
1100+
lc_request.user.lms_user_id,
1101+
)
1102+
lc_request.assignment = assignment
1103+
lc_request.save()
1104+
lc_request.approve(request.user)
1105+
send_learner_credit_bnr_request_approve_task.delay(
1106+
assignment.uuid
1107+
)
1108+
results["approved"].append(
1109+
serializers.LearnerCreditRequestSerializer(
1110+
lc_request
1111+
).data
1112+
)
1113+
except SubisidyAccessPolicyRequestApprovalError as exc:
1114+
error_msg = (
1115+
f"[LC REQUEST BULK APPROVAL] Failed to approve learner credit request "
1116+
f"with UUID {uuid_val}. Reason: {exc.message}."
1117+
)
1118+
logger.exception(error_msg)
1119+
# Update action with error
1120+
lc_request_action.status = get_user_message_choice(
1121+
SubsidyRequestStates.REQUESTED
1122+
)
1123+
lc_request_action.error_reason = get_error_reason_choice(
1124+
LearnerCreditRequestActionErrorReasons.FAILED_APPROVAL
1125+
)
1126+
lc_request_action.traceback = format_traceback(exc)
1127+
lc_request_action.save()
1128+
results["failed"].append(
1129+
{"uuid": uuid_val, "detail": exc.message}
1130+
)
1131+
1132+
return Response(results, status=status.HTTP_200_OK)
1133+
10241134
@permission_required(
10251135
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
10261136
fn=get_enterprise_uuid_from_request_data,

0 commit comments

Comments
 (0)