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 @@ -57,6 +57,7 @@
CouponCodeRequestSerializer,
LearnerCreditRequestApproveAllSerializer,
LearnerCreditRequestApproveRequestSerializer,
LearnerCreditRequestBulkApproveRequestSerializer,
LearnerCreditRequestCancelSerializer,
LearnerCreditRequestDeclineSerializer,
LearnerCreditRequestRemindSerializer,
Expand Down
43 changes: 43 additions & 0 deletions enterprise_access/apps/api/serializers/subsidy_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions enterprise_access/apps/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
114 changes: 114 additions & 0 deletions enterprise_access/apps/api/v1/tests/test_browse_and_request_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
111 changes: 111 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 @@ -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 (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions enterprise_access/apps/subsidy_request/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down