Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from .subsidy_requests import (
CouponCodeRequestSerializer,
LearnerCreditRequestApproveRequestSerializer,
LearnerCreditRequestBulkApproveRequestSerializer,
LearnerCreditRequestCancelSerializer,
LearnerCreditRequestDeclineSerializer,
LearnerCreditRequestRemindSerializer,
Expand Down
67 changes: 67 additions & 0 deletions enterprise_access/apps/api/serializers/subsidy_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,73 @@ def update(self, instance, validated_data):
raise NotImplementedError("This serializer is for validation only")


# pylint: disable=abstract-method
class LearnerCreditRequestBulkApproveRequestSerializer(
serializers.Serializer
):
"""
Request Serializer to validate learner-credit bulk ``approve`` endpoint POST data.

For view: LearnerCreditRequestViewSet.bulk_approve

Supports two modes:
1. Specific UUID approval: provide subsidy_request_uuids
2. Approve all: set approve_all=True (optionally with query filters)
"""

policy_uuid = serializers.UUIDField(
required=True,
help_text="The UUID of the policy to which the requests belong.",
)
enterprise_customer_uuid = serializers.UUIDField(
required=True,
help_text="The UUID of the Enterprise Customer.",
)
approve_all = serializers.BooleanField(
default=False,
help_text="If True, approve all REQUESTED state requests for the policy. "
"Cannot be used with subsidy_request_uuids.",
)
subsidy_request_uuids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
allow_empty=False,
help_text="List of LearnerCreditRequest UUIDs to approve. Required when approve_all=False.",
)

# pylint: disable=arguments-renamed
def validate(self, data):
"""
Validate that either approve_all=True or subsidy_request_uuids is provided, but not both.
"""
approve_all = data.get("approve_all", False)
subsidy_request_uuids = data.get("subsidy_request_uuids")

if not approve_all and not subsidy_request_uuids:
raise serializers.ValidationError(
"Either provide subsidy_request_uuids or set approve_all=True"
)

if approve_all and subsidy_request_uuids:
raise serializers.ValidationError(
"Cannot specify both approve_all=True and subsidy_request_uuids"
)

return data

def create(self, validated_data):
"""
Not implemented - this serializer is for validation only
"""
raise NotImplementedError("This serializer is for validation only")

def update(self, instance, validated_data):
"""
Not implemented - this serializer is for validation only
"""
raise NotImplementedError("This serializer is for validation only")


# pylint: disable=abstract-method
class LearnerCreditRequestCancelSerializer(serializers.Serializer):
"""
Expand Down
18 changes: 18 additions & 0 deletions enterprise_access/apps/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,21 @@ def get_or_fetch_enterprise_uuid_for_bff_request(request):

# Could not derive enterprise_customer_uuid for the BFF request.
return None


def add_bulk_approve_operation_result(
results_dict, category, uuid, state, detail
):
"""
Add a standardized result entry to a bulk operation results dictionary.

Args:
results_dict (dict): Dictionary containing categorized results
category (str): Result category (e.g., 'approved', 'failed', 'skipped', 'not_found')
uuid (str): UUID of the request being processed
state (str|None): Current state of the request, or None if not applicable
detail (str): Descriptive message about the operation result
"""
results_dict[category].append(
{"uuid": str(uuid), "state": state, "detail": detail}
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
REASON_POLICY_SPEND_LIMIT_REACHED,
REASON_SUBSIDY_EXPIRED
)
from enterprise_access.apps.subsidy_access_policy.exceptions import SubsidyAccessPolicyLockAttemptFailed
from enterprise_access.apps.subsidy_access_policy.exceptions import (
SubisidyAccessPolicyRequestApprovalError,
SubsidyAccessPolicyLockAttemptFailed
)
from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
PerLearnerSpendCapLearnerCreditAccessPolicyFactory
Expand Down Expand Up @@ -2879,6 +2882,94 @@ def test_cancel_success(self, mock_cancel_assignments):
).first()
assert success_action is not None

@mock.patch(
"enterprise_access.apps.api.v1.views.browse_and_request.approve_learner_credit_request_via_policy"
)
def test_bulk_approve_mixed_success(self, mock_approve):
"""
Test bulk approve returns partial success without failing the whole request.
"""
# Set admin context for the correct enterprise
self.set_jwt_cookie(
[
{
"system_wide_role": SYSTEM_ENTERPRISE_ADMIN_ROLE,
"context": str(self.enterprise_customer_uuid_1),
}
]
)

# One request will approve, one will fail, one skipped
requested_ok = self.user_request_1 # requested, will approve
requested_fail = LearnerCreditRequestFactory(
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
user=self.user,
learner_credit_request_config=self.learner_credit_config,
course_price=1200,
state=SubsidyRequestStates.REQUESTED,
assignment=None,
)
skipped_req = LearnerCreditRequestFactory(
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
learner_credit_request_config=self.learner_credit_config,
state=SubsidyRequestStates.APPROVED,
)

# Configure approve side effects
def approve_side_effect(
_policy_uuid,
content_key,
content_price_cents,
learner_email,
lms_user_id,
):
if (str(requested_fail.user.lms_user_id) == str(lms_user_id) and
content_price_cents == requested_fail.course_price):
raise SubisidyAccessPolicyRequestApprovalError(
"policy validation failed", 422
)
# Return a basic assignment via factory
return LearnerContentAssignmentFactory(
assignment_configuration=self.assignment_config,
learner_email=learner_email,
lms_user_id=lms_user_id,
content_key=content_key,
content_quantity=-abs(content_price_cents),
state="allocated",
)

mock_approve.side_effect = approve_side_effect

url = reverse("api:v1:learner-credit-requests-bulk-approve")
payload = {
"enterprise_customer_uuid": str(self.enterprise_customer_uuid_1),
"policy_uuid": str(self.policy.uuid),
"subsidy_request_uuids": [
str(requested_ok.uuid),
str(requested_fail.uuid),
str(skipped_req.uuid),
str(uuid4()), # not found
],
}
response = self.client.post(url, payload)
assert response.status_code == status.HTTP_200_OK

data = response.json()
assert len(data["approved"]) == 1
assert len(data["failed"]) == 1
assert len(data["skipped"]) == 1

requested_ok.refresh_from_db()
requested_fail.refresh_from_db()
skipped_req.refresh_from_db()

assert requested_ok.state == SubsidyRequestStates.APPROVED
assert requested_fail.state in [
SubsidyRequestStates.REQUESTED,
SubsidyRequestStates.ERROR,
]
assert skipped_req.state == SubsidyRequestStates.APPROVED

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need a unit test for approve_all as well.

@mock.patch('enterprise_access.apps.content_assignments.api.cancel_assignments')
def test_cancel_failed_assignment_cancellation(self, mock_cancel_assignments):
"""
Expand Down
147 changes: 147 additions & 0 deletions enterprise_access/apps/api/v1/views/browse_and_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
update_license_requests_after_assignments_task
)
from enterprise_access.apps.api.utils import (
add_bulk_approve_operation_result,
get_enterprise_uuid_from_query_params,
get_enterprise_uuid_from_request_data,
validate_uuid
Expand Down Expand Up @@ -734,6 +735,17 @@ def decline(self, *args, **kwargs):
summary='Approve a learner credit request.',
request=serializers.LearnerCreditRequestApproveRequestSerializer,
),
bulk_approve=extend_schema(
tags=['Learner Credit Requests'],
summary='Bulk approve learner credit requests.',
description=(
'Bulk approve learner credit requests. Supports two modes:\n'
'1. Specific UUID approval: provide subsidy_request_uuids\n'
'2. Approve all: set approve_all=True (optionally with query filters)\n\n'
'Response contains categorized results with uuid, state, and detail for each request.'
),
request=serializers.LearnerCreditRequestBulkApproveRequestSerializer,
),
overview=extend_schema(
tags=['Learner Credit Requests'],
summary='Learner credit request overview.',
Expand Down Expand Up @@ -1022,6 +1034,141 @@ def approve(self, request, *args, **kwargs):
lc_request_action.save()
return Response({"detail": error_msg}, exc.status_code)

@permission_required(
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
fn=get_enterprise_uuid_from_request_data,
)
@action(detail=False, url_path="bulk-approve", methods=["post"])
def bulk_approve(self, request, *args, **kwargs):
"""
Bulk approve learner credit requests.

Supports two modes:
1. Specific UUID approval: provide subsidy_request_uuids
2. Approve all: set approve_all=True (optionally with query filters)

Processes each request independently and returns a summary with
approved and failed items. Partial success is allowed.
"""
serializer = (
serializers.LearnerCreditRequestBulkApproveRequestSerializer(
data=request.data
)
)
serializer.is_valid(raise_exception=True)
policy_uuid = serializer.validated_data["policy_uuid"]
approve_all = serializer.validated_data.get("approve_all", False)

if approve_all:
base_queryset = LearnerCreditRequest.objects.filter(
state=SubsidyRequestStates.REQUESTED,
learner_credit_request_config__learner_credit_config__uuid=policy_uuid,
).select_related("user")

requests_to_process = self.filter_queryset(base_queryset)

requests_by_uuid = {
str(req.uuid): req for req in requests_to_process
}
else:
subsidy_request_uuids = serializer.validated_data["subsidy_request_uuids"]
requests_by_uuid = {
str(req.uuid): req
for req in LearnerCreditRequest.objects.select_related(
"user"
).filter(uuid__in=subsidy_request_uuids)
}

results = {"approved": [], "failed": [], "not_found": [], "skipped": []}

approved_requests = []
successful_request_data = []

for uuid_val, lc_request in requests_by_uuid.items():
if (not approve_all and lc_request.state != SubsidyRequestStates.REQUESTED):
add_bulk_approve_operation_result(
results, "skipped", uuid_val, lc_request.state,
f"Request already in {lc_request.state} state"
)
continue

learner_email = lc_request.user.email
content_key = lc_request.course_id
content_price_cents = lc_request.course_price

lc_request_action = LearnerCreditRequestActions.create_action(
learner_credit_request=lc_request,
recent_action=get_action_choice(
SubsidyRequestStates.APPROVED
),
status=get_user_message_choice(SubsidyRequestStates.APPROVED),
)

try:
with transaction.atomic():
assignment = approve_learner_credit_request_via_policy(
policy_uuid,
content_key,
content_price_cents,
learner_email,
lc_request.user.lms_user_id,
)

# Prepare for bulk processing instead of individual saves
lc_request.assignment = assignment

approved_requests.append(lc_request)
successful_request_data.append({
'uuid': uuid_val,
'state': SubsidyRequestStates.APPROVED,
'message': "Successfully approved",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field can be left empty for approvals. In fact, it’s better to rename it to “error,” so the frontend can safely assume it only appears for failed requests.

'assignment_uuid': assignment.uuid
})

except SubisidyAccessPolicyRequestApprovalError as exc:
error_msg = (
f"[LC REQUEST BULK APPROVAL] Failed to approve learner credit request "
f"with UUID {uuid_val}. Reason: {exc.message}."
)
logger.exception(error_msg)
# Update action with error
lc_request_action.status = get_user_message_choice(
SubsidyRequestStates.REQUESTED
)
lc_request_action.error_reason = get_error_reason_choice(
LearnerCreditRequestActionErrorReasons.FAILED_APPROVAL
)
lc_request_action.traceback = format_traceback(exc)
lc_request_action.save()
add_bulk_approve_operation_result(results, "failed", uuid_val, lc_request.state, exc.message)

if approved_requests:
try:
with transaction.atomic():
LearnerCreditRequest.bulk_approve_requests(approved_requests, request.user)

# Send notifications and record results
for request_data in successful_request_data:
send_learner_credit_bnr_request_approve_task.delay(request_data['assignment_uuid'])
add_bulk_approve_operation_result(
results,
"approved",
request_data['uuid'],
request_data['state'],
request_data['message'],
)

except (ValidationError, IntegrityError, DatabaseError) as exc:
error_msg = f"[LC REQUEST BULK APPROVAL] Bulk update failed: {exc}"
logger.exception(error_msg)
for request_data in successful_request_data:
add_bulk_approve_operation_result(
results, "failed", request_data['uuid'],
SubsidyRequestStates.REQUESTED, str(exc)
)
Comment on lines +1147 to +1168
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this indicate that we are either going to process all requests or none in case of failures?


return Response(results, status=status.HTTP_200_OK)

@permission_required(
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
fn=get_enterprise_uuid_from_request_data,
Expand Down
Loading