diff --git a/enterprise_access/apps/api/serializers/__init__.py b/enterprise_access/apps/api/serializers/__init__.py index 594328b2..fa26e3ef 100644 --- a/enterprise_access/apps/api/serializers/__init__.py +++ b/enterprise_access/apps/api/serializers/__init__.py @@ -57,6 +57,7 @@ CouponCodeRequestSerializer, LearnerCreditRequestApproveAllSerializer, 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 63fce06e..bc8ae028 100644 --- a/enterprise_access/apps/api/serializers/subsidy_requests.py +++ b/enterprise_access/apps/api/serializers/subsidy_requests.py @@ -412,3 +412,46 @@ def get_learner_credit_request(self): Return the already-fetched learner credit request object. """ return getattr(self, '_learner_credit_request', None) + + +class LearnerCreditRequestBulkApproveRequestSerializer(serializers.Serializer): + """ + Serializer for bulk approving learner credit requests. + """ + policy_uuid = serializers.UUIDField( + required=True, + help_text='The UUID of the subsidy access policy to use for approval.', + ) + enterprise_customer_uuid = serializers.UUIDField( + required=True, + help_text='The UUID of the enterprise customer.', + ) + approve_all = serializers.BooleanField( + required=False, + default=False, + help_text='If true, approve all pending requests for the enterprise customer.', + ) + subsidy_request_uuids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + help_text='List of subsidy request UUIDs to approve.', + ) + + def validate(self, attrs): + """ + Validate that either approve_all is True or subsidy_request_uuids is provided, but not both. + """ + approve_all = attrs.get('approve_all', False) + subsidy_request_uuids = attrs.get('subsidy_request_uuids', []) + + if approve_all and subsidy_request_uuids: + raise serializers.ValidationError( + 'Cannot specify both approve_all and subsidy_request_uuids. Please choose one.' + ) + + if not approve_all and not subsidy_request_uuids: + raise serializers.ValidationError( + 'Must specify either approve_all=True or provide subsidy_request_uuids.' + ) + + return attrs diff --git a/enterprise_access/apps/api/utils.py b/enterprise_access/apps/api/utils.py index b2549859..5230cb47 100644 --- a/enterprise_access/apps/api/utils.py +++ b/enterprise_access/apps/api/utils.py @@ -116,3 +116,20 @@ 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, error=None): + """ + Add an operation result to the results dictionary. + + Args: + results_dict: Dictionary containing 'approved' and 'failed' lists + category: Either 'approved' or 'failed' + uuid: The UUID of the request + state: The state of the request + error: Optional error message for failed operations + """ + result = {'uuid': str(uuid), 'state': state} + if error: + result['error'] = error + results_dict[category].append(result) 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 70c09787..9a885083 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 @@ -3243,3 +3243,117 @@ def test_list_ordering_latest_action_type(self, ordering_key): else: self.assertGreater(first_learner_credit_request_position, second_learner_credit_request_position, "'approved' action type should sort after 'requested' in descending order") + + @mock.patch(BNR_VIEW_PATH + '.send_learner_credit_bnr_request_approve_task') + @mock.patch(BNR_VIEW_PATH + '.assignments_api.allocate_assignment_for_requests') + def test_bulk_approve_mixed_success( + self, mock_allocate_assignment_for_requests, mock_send_approve_task + ): + """ + Test bulk_approve with mixed success and failure results. + """ + self.set_jwt_cookie([{ + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, + 'context': str(self.enterprise_customer_uuid_1) + }]) + + request_1 = LearnerCreditRequestFactory( + enterprise_customer_uuid=self.enterprise_customer_uuid_1, + user=self.user, + state=SubsidyRequestStates.REQUESTED, + ) + request_2 = LearnerCreditRequestFactory( + enterprise_customer_uuid=self.enterprise_customer_uuid_1, + user=self.user, + state=SubsidyRequestStates.REQUESTED, + ) + + # Use a real LearnerContentAssignment instance instead of MagicMock + assignment_for_request_1 = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_config, + content_quantity=-500, + state='allocated', + ) + # Only return assignment for request_1, not request_2 + mock_allocate_assignment_for_requests.return_value = { + request_1.uuid: assignment_for_request_1, + } + + response = self.client.post( + reverse('api:v1:learner-credit-requests-bulk-approve'), + data={ + 'policy_uuid': str(self.policy.uuid), + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), + 'subsidy_request_uuids': [str(request_1.uuid), str(request_2.uuid)], + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.json() + self.assertEqual(len(results['approved']), 1) + self.assertEqual(len(results['failed']), 1) + self.assertEqual(results['approved'][0]['uuid'], str(request_1.uuid)) + self.assertEqual(results['failed'][0]['uuid'], str(request_2.uuid)) + self.assertIn('error', results['failed'][0]) + + @mock.patch(BNR_VIEW_PATH + '.send_learner_credit_bnr_request_approve_task') + @mock.patch(BNR_VIEW_PATH + '.assignments_api.allocate_assignment_for_requests') + def test_bulk_approve_all_success( + self, mock_allocate_assignment_for_requests, mock_send_approve_task + ): + """ + Test bulk_approve with approve_all=True. + """ + # Change state of setup requests so they don't interfere with approve_all query + self.user_request_1.state = SubsidyRequestStates.APPROVED + self.user_request_1.save() + self.enterprise_request.state = SubsidyRequestStates.APPROVED + self.enterprise_request.save() + + self.set_jwt_cookie([{ + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, + 'context': str(self.enterprise_customer_uuid_1) + }]) + + request_1 = LearnerCreditRequestFactory( + enterprise_customer_uuid=self.enterprise_customer_uuid_1, + user=self.user, + state=SubsidyRequestStates.REQUESTED, + ) + request_2 = LearnerCreditRequestFactory( + enterprise_customer_uuid=self.enterprise_customer_uuid_1, + user=self.user, + state=SubsidyRequestStates.REQUESTED, + ) + + # Use real LearnerContentAssignment instances instead of MagicMock + assignment_1 = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_config, + content_quantity=-500, + state='allocated', + ) + assignment_2 = LearnerContentAssignmentFactory( + assignment_configuration=self.assignment_config, + content_quantity=-500, + state='allocated', + ) + mock_allocate_assignment_for_requests.return_value = { + request_1.uuid: assignment_1, + request_2.uuid: assignment_2, + } + + response = self.client.post( + reverse('api:v1:learner-credit-requests-bulk-approve'), + data={ + 'policy_uuid': str(self.policy.uuid), + 'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1), + 'approve_all': True, + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.json() + self.assertEqual(len(results['approved']), 2) + self.assertEqual(len(results['failed']), 0) 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 4d6f406e..e5b3ca8b 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 @@ -67,6 +68,7 @@ send_learner_credit_bnr_admins_email_with_new_requests_task, send_learner_credit_bnr_cancel_notification_task, send_learner_credit_bnr_decline_notification_task, + send_learner_credit_bnr_request_approve_task, send_reminder_email_for_pending_learner_credit_request ) from enterprise_access.apps.subsidy_request.utils import ( @@ -744,6 +746,11 @@ def decline(self, *args, **kwargs): cancel=extend_schema( tags=['Learner Credit Requests'], summary='Learner credit request cancel endpoint.', + ), + bulk_approve=extend_schema( + tags=['Learner Credit Requests'], + summary='Bulk approve learner credit requests.', + request=serializers.LearnerCreditRequestBulkApproveRequestSerializer, ) ) class LearnerCreditRequestViewSet(SubsidyRequestViewSet): @@ -1235,6 +1242,110 @@ def decline(self, *args, **kwargs): return Response(serialized_request, status=status.HTTP_200_OK) + @action(detail=False, methods=['post'], url_path='bulk-approve') + @permission_required( + constants.REQUESTS_ADMIN_LEARNER_ACCESS_PERMISSION, + fn=lambda request: get_enterprise_uuid_from_request_data(request), + ) + def bulk_approve(self, request, *args, **kwargs): + """ + Bulk approve learner credit requests. + """ + serializer = serializers.LearnerCreditRequestBulkApproveRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + policy_uuid = serializer.validated_data['policy_uuid'] + enterprise_customer_uuid = serializer.validated_data['enterprise_customer_uuid'] + approve_all = serializer.validated_data.get('approve_all', False) + subsidy_request_uuids = serializer.validated_data.get('subsidy_request_uuids', []) + + try: + policy = SubsidyAccessPolicy.objects.get(uuid=policy_uuid) + except SubsidyAccessPolicy.DoesNotExist: + return Response( + {'error': f'Policy with uuid {policy_uuid} not found.'}, + status=status.HTTP_404_NOT_FOUND + ) + + results = {'approved': [], 'failed': []} + + if approve_all: + pending_requests = LearnerCreditRequest.objects.filter( + enterprise_customer_uuid=enterprise_customer_uuid, + state=SubsidyRequestStates.REQUESTED + ) + else: + pending_requests = LearnerCreditRequest.objects.filter( + uuid__in=subsidy_request_uuids, + enterprise_customer_uuid=enterprise_customer_uuid + ) + + # Filter to only REQUESTED state + valid_requests = [] + for learner_credit_request in pending_requests: + if learner_credit_request.state != SubsidyRequestStates.REQUESTED: + add_bulk_approve_operation_result( + results, + 'failed', + learner_credit_request.uuid, + learner_credit_request.state, + error=f'Request is not in a requestable state: {learner_credit_request.state}' + ) + else: + valid_requests.append(learner_credit_request) + + if not valid_requests: + return Response(results, status=status.HTTP_200_OK) + + # Allocate assignments in bulk + try: + assignment_map = assignments_api.allocate_assignment_for_requests( + policy.assignment_configuration, + valid_requests, + ) + except Exception as exc: # pylint: disable=broad-except + for learner_credit_request in valid_requests: + add_bulk_approve_operation_result( + results, + 'failed', + learner_credit_request.uuid, + learner_credit_request.state, + error=str(exc) + ) + return Response(results, status=status.HTTP_200_OK) + + # Process results + requests_to_approve = [] + for learner_credit_request in valid_requests: + assignment = assignment_map.get(learner_credit_request.uuid) + if assignment: + learner_credit_request.assignment = assignment + requests_to_approve.append(learner_credit_request) + else: + add_bulk_approve_operation_result( + results, + 'failed', + learner_credit_request.uuid, + learner_credit_request.state, + error='Failed to allocate assignment' + ) + + if requests_to_approve: + LearnerCreditRequest.bulk_approve_requests(requests_to_approve, request.user) + + for learner_credit_request in requests_to_approve: + add_bulk_approve_operation_result( + results, + 'approved', + learner_credit_request.uuid, + SubsidyRequestStates.APPROVED + ) + send_learner_credit_bnr_request_approve_task.delay( + learner_credit_request.uuid + ) + + return Response(results, status=status.HTTP_200_OK) + @extend_schema_view( retrieve=extend_schema( diff --git a/enterprise_access/apps/subsidy_request/models.py b/enterprise_access/apps/subsidy_request/models.py index cac1c5a2..753f755c 100644 --- a/enterprise_access/apps/subsidy_request/models.py +++ b/enterprise_access/apps/subsidy_request/models.py @@ -556,6 +556,23 @@ def bulk_update(cls, lcr_records, updated_field_names): batch_size=SUBSIDY_REQUEST_BULK_OPERATION_BATCH_SIZE, ) + @classmethod + def bulk_approve_requests(cls, approved_requests, reviewer): + """ + Bulk approve learner credit requests. + + Args: + approved_requests: List of LearnerCreditRequest objects to approve + reviewer: The user who is approving the requests + """ + reviewed_at = localized_utcnow() + for request in approved_requests: + request.state = SubsidyRequestStates.APPROVED + request.reviewer = reviewer + request.reviewed_at = reviewed_at + + cls.bulk_update(approved_requests, ['state', 'reviewer', 'reviewed_at', 'assignment']) + class LearnerCreditRequestActions(TimeStampedModel): """