diff --git a/enterprise_access/apps/api/serializers/__init__.py b/enterprise_access/apps/api/serializers/__init__.py index b59777f2d..2f0787318 100644 --- a/enterprise_access/apps/api/serializers/__init__.py +++ b/enterprise_access/apps/api/serializers/__init__.py @@ -49,6 +49,7 @@ from .subsidy_requests import ( CouponCodeRequestSerializer, LearnerCreditRequestApproveRequestSerializer, + LearnerCreditRequestBulkApproveRequestSerializer, LearnerCreditRequestCancelSerializer, LearnerCreditRequestDeclineSerializer, LearnerCreditRequestRemindSerializer, diff --git a/enterprise_access/apps/api/serializers/subsidy_requests.py b/enterprise_access/apps/api/serializers/subsidy_requests.py index 0483ecb30..14b0d1c11 100644 --- a/enterprise_access/apps/api/serializers/subsidy_requests.py +++ b/enterprise_access/apps/api/serializers/subsidy_requests.py @@ -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): """ diff --git a/enterprise_access/apps/api/utils.py b/enterprise_access/apps/api/utils.py index b2549859b..14bdb4a9e 100644 --- a/enterprise_access/apps/api/utils.py +++ b/enterprise_access/apps/api/utils.py @@ -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} + ) diff --git a/enterprise_access/apps/api/v1/tests/test_browse_and_request_views.py b/enterprise_access/apps/api/v1/tests/test_browse_and_request_views.py index 95d868512..2c95c1f84 100644 --- a/enterprise_access/apps/api/v1/tests/test_browse_and_request_views.py +++ b/enterprise_access/apps/api/v1/tests/test_browse_and_request_views.py @@ -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 @@ -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 + @mock.patch('enterprise_access.apps.content_assignments.api.cancel_assignments') def test_cancel_failed_assignment_cancellation(self, mock_cancel_assignments): """ diff --git a/enterprise_access/apps/api/v1/views/browse_and_request.py b/enterprise_access/apps/api/v1/views/browse_and_request.py index 63f2b9e26..414ffa330 100644 --- a/enterprise_access/apps/api/v1/views/browse_and_request.py +++ b/enterprise_access/apps/api/v1/views/browse_and_request.py @@ -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 @@ -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.', @@ -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", + '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) + ) + + return Response(results, status=status.HTTP_200_OK) + @permission_required( constants.REQUESTS_ADMIN_ACCESS_PERMISSION, fn=get_enterprise_uuid_from_request_data, diff --git a/enterprise_access/apps/subsidy_request/models.py b/enterprise_access/apps/subsidy_request/models.py index 0a3b5fde2..b47d053e3 100644 --- a/enterprise_access/apps/subsidy_request/models.py +++ b/enterprise_access/apps/subsidy_request/models.py @@ -435,6 +435,35 @@ def approve(self, reviewer): self.reviewed_at = localized_utcnow() self.save() + @classmethod + def bulk_approve_requests(cls, approved_requests, reviewer): + """ + Efficiently bulk approve learner credit requests using batching. + + Args: + approved_requests: List of LearnerCreditRequest instances to approve + reviewer: User instance who is approving the requests + + Returns: + None - requests are updated in-place + """ + # Prepare all requests for bulk update + for request in approved_requests: + request.state = SubsidyRequestStates.APPROVED + request.reviewer = reviewer + request.reviewed_at = localized_utcnow() + + # Perform bulk update in batches + batch_size = SUBSIDY_REQUEST_BULK_OPERATION_BATCH_SIZE + total_requests = len(approved_requests) + + for i in range(0, total_requests, batch_size): + batch_requests = approved_requests[i: i + batch_size] + cls.bulk_update( + batch_requests, + ["state", "assignment", "reviewer", "reviewed_at"], + ) + @classmethod def annotate_dynamic_fields_onto_queryset(cls, queryset): """