Skip to content

Commit dea2c43

Browse files
feat: bulk remind for lcr
1 parent 2c2a59c commit dea2c43

File tree

7 files changed

+286
-49
lines changed

7 files changed

+286
-49
lines changed

enterprise_access/apps/api/serializers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
LearnerCreditRequestApproveRequestSerializer,
5252
LearnerCreditRequestCancelSerializer,
5353
LearnerCreditRequestDeclineSerializer,
54+
LearnerCreditRequestRemindAllSerializer,
5455
LearnerCreditRequestRemindSerializer,
5556
LearnerCreditRequestSerializer,
5657
LicenseRequestSerializer,

enterprise_access/apps/api/serializers/subsidy_requests.py

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -342,45 +342,25 @@ def get_learner_credit_request(self):
342342

343343
class LearnerCreditRequestRemindSerializer(serializers.Serializer):
344344
"""
345-
Request serializer to validate remind endpoint for a LearnerCreditRequest.
345+
Request serializer to validate remind endpoint for a list of LearnerCreditRequests.
346346
347347
For view: LearnerCreditRequestViewSet.remind
348348
"""
349-
learner_credit_request_uuid = serializers.UUIDField(
349+
learner_credit_request_uuids = serializers.ListField(
350+
child=serializers.UUIDField(),
350351
required=True,
351-
help_text="The UUID of the LearnerCreditRequest to be reminded."
352+
allow_empty=False,
353+
help_text="A list of LearnerCreditRequest UUIDs to be reminded."
352354
)
353355

354-
def __init__(self, *args, **kwargs):
355-
super().__init__(*args, **kwargs)
356-
self._learner_credit_request = None
357-
358-
def validate_learner_credit_request_uuid(self, value):
359-
"""
360-
Validate that the learner credit request exists, has an associated assignment,
361-
and is in a state where a reminder is appropriate.
362-
"""
363-
try:
364-
learner_credit_request = LearnerCreditRequest.objects.select_related('assignment').get(uuid=value)
365-
except LearnerCreditRequest.DoesNotExist as exc:
366-
raise serializers.ValidationError(f"Learner credit request with uuid {value} not found.") from exc
367-
368-
if learner_credit_request.state != SubsidyRequestStates.APPROVED:
369-
raise serializers.ValidationError(
370-
f"Cannot send a reminder for a request in the '{learner_credit_request.state}' state. "
371-
"Reminders can only be sent for 'APPROVED' requests."
372-
)
373-
374-
if not learner_credit_request.assignment:
375-
raise serializers.ValidationError(
376-
f"The learner credit request with uuid {value} does not have an associated assignment."
377-
)
378356

379-
self._learner_credit_request = learner_credit_request
380-
return value
357+
class LearnerCreditRequestRemindAllSerializer(serializers.Serializer):
358+
"""
359+
Request serializer to validate remind-all endpoint for LearnerCreditRequests.
381360
382-
def get_learner_credit_request(self):
383-
"""
384-
Return the already-fetched learner credit request object.
385-
"""
386-
return getattr(self, '_learner_credit_request', None)
361+
For view: LearnerCreditRequestViewSet.remind_all
362+
"""
363+
policy_uuid = serializers.UUIDField(
364+
required=True,
365+
help_text='The UUID of the policy to which the requests belong.',
366+
)

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2788,6 +2788,139 @@ def test_approve_policy_lock_failure(
27882788
assert self.user_request_1.state == SubsidyRequestStates.REQUESTED
27892789
assert self.user_request_1.assignment is None
27902790

2791+
@mock.patch(
2792+
'enterprise_access.apps.api.v1.views.browse_and_request.subsidy_request_api.remind_learner_credit_requests'
2793+
)
2794+
def test_remind_success(self, mock_remind_api):
2795+
"""
2796+
Test that the remind endpoint returns 200 OK when all requests are remindable.
2797+
"""
2798+
self.set_jwt_cookie([{
2799+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2800+
'context': str(self.enterprise_customer_uuid_1)
2801+
}])
2802+
remindable_request = LearnerCreditRequestFactory(
2803+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2804+
state=SubsidyRequestStates.APPROVED,
2805+
assignment=LearnerContentAssignmentFactory(
2806+
assignment_configuration=self.assignment_config
2807+
),
2808+
)
2809+
mock_remind_api.return_value = {
2810+
'remindable_requests': [remindable_request],
2811+
'non_remindable_requests': []
2812+
}
2813+
url = reverse('api:v1:learner-credit-requests-remind')
2814+
data = {
2815+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2816+
'learner_credit_request_uuids': [str(remindable_request.uuid)]
2817+
}
2818+
response = self.client.post(url, data)
2819+
assert response.status_code == status.HTTP_200_OK
2820+
mock_remind_api.assert_called_once()
2821+
2822+
@mock.patch(
2823+
'enterprise_access.apps.api.v1.views.browse_and_request.subsidy_request_api.remind_learner_credit_requests'
2824+
)
2825+
def test_remind_no_remindable_requests(self, mock_remind_api):
2826+
"""
2827+
Test that the remind endpoint returns 200 OK when all requests are remindable.
2828+
"""
2829+
self.set_jwt_cookie([{
2830+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2831+
'context': str(self.enterprise_customer_uuid_1)
2832+
}])
2833+
remindable_request = LearnerCreditRequestFactory(
2834+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2835+
state=SubsidyRequestStates.REQUESTED,
2836+
assignment=LearnerContentAssignmentFactory(
2837+
assignment_configuration=self.assignment_config
2838+
),
2839+
)
2840+
mock_remind_api.return_value = {
2841+
'remindable_requests': [],
2842+
'non_remindable_requests': [remindable_request]
2843+
}
2844+
url = reverse('api:v1:learner-credit-requests-remind')
2845+
data = {
2846+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2847+
'learner_credit_request_uuids': [str(remindable_request.uuid)]
2848+
}
2849+
response = self.client.post(url, data)
2850+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
2851+
mock_remind_api.assert_called_once()
2852+
2853+
@mock.patch(
2854+
'enterprise_access.apps.api.v1.views.browse_and_request.subsidy_request_api.remind_learner_credit_requests'
2855+
)
2856+
def test_remind_all_success(self, mock_remind_api):
2857+
"""
2858+
Test that the remind-all endpoint returns 202 ACCEPTED when all requests are remindable.
2859+
"""
2860+
self.set_jwt_cookie([{
2861+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2862+
'context': str(self.enterprise_customer_uuid_1)
2863+
}])
2864+
remindable_request = LearnerCreditRequestFactory(
2865+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2866+
state=SubsidyRequestStates.APPROVED,
2867+
assignment=LearnerContentAssignmentFactory(
2868+
assignment_configuration=self.assignment_config
2869+
),
2870+
learner_credit_request_config=self.learner_credit_config,
2871+
)
2872+
mock_remind_api.return_value = {
2873+
'remindable_requests': [remindable_request],
2874+
'non_remindable_requests': []
2875+
}
2876+
url = reverse('api:v1:learner-credit-requests-remind-all')
2877+
data = {
2878+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2879+
'policy_uuid': str(self.policy.uuid)
2880+
}
2881+
response = self.client.post(url, data)
2882+
assert response.status_code == status.HTTP_202_ACCEPTED
2883+
mock_remind_api.assert_called_once()
2884+
2885+
@mock.patch(
2886+
'enterprise_access.apps.api.v1.views.browse_and_request.subsidy_request_api.remind_learner_credit_requests'
2887+
)
2888+
def test_remind_all_non_remindable_requests(self, mock_remind_api):
2889+
"""
2890+
Test that remind-all returns 422 if there are non-remindable requests.
2891+
"""
2892+
self.set_jwt_cookie([{
2893+
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
2894+
'context': str(self.enterprise_customer_uuid_1)
2895+
}])
2896+
remindable_request = LearnerCreditRequestFactory(
2897+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2898+
state=SubsidyRequestStates.APPROVED,
2899+
assignment=LearnerContentAssignmentFactory(
2900+
assignment_configuration=self.assignment_config
2901+
),
2902+
learner_credit_request_config=self.learner_credit_config,
2903+
)
2904+
non_remindable_request = LearnerCreditRequestFactory(
2905+
enterprise_customer_uuid=self.enterprise_customer_uuid_1,
2906+
state=SubsidyRequestStates.REQUESTED,
2907+
assignment=None,
2908+
learner_credit_request_config=self.learner_credit_config,
2909+
)
2910+
mock_remind_api.return_value = {
2911+
'remindable_requests': [remindable_request],
2912+
'non_remindable_requests': [non_remindable_request]
2913+
}
2914+
url = reverse('api:v1:learner-credit-requests-remind-all')
2915+
data = {
2916+
'enterprise_customer_uuid': str(self.enterprise_customer_uuid_1),
2917+
'policy_uuid': str(self.policy.uuid)
2918+
}
2919+
response = self.client.post(url, data)
2920+
2921+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
2922+
mock_remind_api.assert_called_once()
2923+
27912924
def test_cancel_invalid_request_uuid(self):
27922925
"""
27932926
Test cancel with invalid UUID format returns 400.

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

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from enterprise_access.apps.subsidy_access_policy.api import approve_learner_credit_request_via_policy
5050
from enterprise_access.apps.subsidy_access_policy.exceptions import SubisidyAccessPolicyRequestApprovalError
5151
from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy
52+
from enterprise_access.apps.subsidy_request import api as subsidy_request_api
5253
from enterprise_access.apps.subsidy_request.constants import (
5354
REUSABLE_REQUEST_STATES,
5455
LearnerCreditAdditionalActionStates,
@@ -1093,27 +1094,79 @@ def cancel(self, request, *args, **kwargs):
10931094
@action(detail=False, url_path="remind", methods=["post"])
10941095
def remind(self, request, *args, **kwargs):
10951096
"""
1096-
Remind a Learner that their LearnerCreditRequest is Approved and waiting for their action.
1097+
Send reminders to a list of learners with associated ``LearnerCreditRequests``
1098+
record by list of uuids.
1099+
1100+
This action is idempotent and will only send reminders for requests
1101+
that are in a valid, remindable state (e.g., 'APPROVED').
10971102
"""
10981103
serializer = serializers.LearnerCreditRequestRemindSerializer(data=request.data)
10991104
serializer.is_valid(raise_exception=True)
1100-
learner_credit_request = serializer.get_learner_credit_request()
1101-
assignment = learner_credit_request.assignment
1102-
1103-
action_instance = LearnerCreditRequestActions.create_action(
1104-
learner_credit_request=learner_credit_request,
1105-
recent_action=get_action_choice(LearnerCreditAdditionalActionStates.REMINDED),
1106-
status=get_user_message_choice(LearnerCreditAdditionalActionStates.REMINDED),
1105+
request_uuids = serializer.validated_data['learner_credit_request_uuids']
1106+
learner_credit_requests = self.get_queryset().select_related('assignment').filter(
1107+
uuid__in=request_uuids
11071108
)
11081109

1110+
if len(learner_credit_requests) != len(set(request_uuids)):
1111+
return Response(
1112+
status=status.HTTP_404_NOT_FOUND
1113+
)
1114+
11091115
try:
1110-
send_reminder_email_for_pending_learner_credit_request.delay(assignment.uuid)
1116+
response = subsidy_request_api.remind_learner_credit_requests(learner_credit_requests)
1117+
if response.get('non_remindable_requests'):
1118+
return Response(
1119+
status=status.HTTP_422_UNPROCESSABLE_ENTITY
1120+
)
11111121
return Response(status=status.HTTP_200_OK)
1112-
except Exception as exc: # pylint: disable=broad-except
1113-
# Optionally log an errored action here if the task couldn't be queued
1114-
action_instance.status = get_user_message_choice(LearnerCreditRequestActionErrorReasons.EMAIL_ERROR)
1115-
action_instance.error_reason = str(exc)
1116-
action_instance.save()
1122+
except Exception: # pylint: disable=broad-except
1123+
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)
1124+
1125+
@permission_required(
1126+
constants.REQUESTS_ADMIN_ACCESS_PERMISSION,
1127+
fn=get_enterprise_uuid_from_request_data,
1128+
)
1129+
@action(detail=False, url_path="remind-all", methods=["post"], pagination_class=None)
1130+
def remind_all(self, request, *args, **kwargs):
1131+
"""
1132+
Send reminders for all selected learner credit requests that are in a remindable state.
1133+
1134+
This endpoint respects the filters applied in the request (e.g., by policy_uuid),
1135+
allowing admins to send bulk reminders to a specific subset of requests.
1136+
1137+
```
1138+
Raises:
1139+
404 if no remindable learner credit requests were found
1140+
422 if any of the learner credit requests threw an error (not found or not remindable)
1141+
```
1142+
"""
1143+
serializer = serializers.LearnerCreditRequestRemindAllSerializer(data=request.data)
1144+
serializer.is_valid(raise_exception=True)
1145+
policy_uuid = serializer.validated_data['policy_uuid']
1146+
1147+
# A request is only remindable if it is in the 'APPROVED' state.
1148+
learner_credit_requests = self.get_queryset().filter(
1149+
state=SubsidyRequestStates.APPROVED,
1150+
learner_credit_request_config__learner_credit_config__uuid=policy_uuid
1151+
)
1152+
1153+
if not learner_credit_requests.exists():
1154+
return Response(status=status.HTTP_404_NOT_FOUND)
1155+
1156+
try:
1157+
response = subsidy_request_api.remind_learner_credit_requests(learner_credit_requests)
1158+
if non_remindable_requests := response.get('non_remindable_requests'):
1159+
# This is very unlikely to occur, because we filter down to only the remindable
1160+
# requests before calling `remind_learner_credit_requests()`, and that function
1161+
# only declares requests to be non-remindable if they are not
1162+
# in the set of remindable states.
1163+
logger.error(
1164+
'There were non-remindable requests in remind-all: %s',
1165+
non_remindable_requests,
1166+
)
1167+
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)
1168+
return Response(status=status.HTTP_202_ACCEPTED)
1169+
except Exception: # pylint: disable=broad-except
11171170
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)
11181171

11191172
@permission_required(
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Primary Python API for interacting with Subsidy Request
3+
records and business logic.
4+
"""
5+
import logging
6+
from typing import Iterable
7+
8+
from enterprise_access.apps.subsidy_request.constants import SubsidyRequestStates
9+
from enterprise_access.apps.subsidy_request.models import LearnerCreditRequest
10+
from enterprise_access.apps.subsidy_request.tasks import send_reminder_email_for_pending_learner_credit_request
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def remind_learner_credit_requests(requests: Iterable[LearnerCreditRequest]) -> dict:
16+
"""
17+
Bulk remind for Learner Credit Requests.
18+
19+
This filters for requests that are in a remindable state and triggers a Celery
20+
task to send a reminder email for each one.
21+
22+
Args:
23+
requests: An iterable of LearnerCreditRequest objects.
24+
25+
Returns:
26+
A dict containing lists of 'remindable_requests' and 'non_remindable_requests' requests.
27+
"""
28+
# A request is only remindable if it is APPROVED and has an associated assignment.
29+
remindable_requests = {
30+
req for req in requests
31+
if req.state == SubsidyRequestStates.APPROVED and req.assignment_id is not None
32+
}
33+
34+
non_remindable_requests = set(requests) - remindable_requests
35+
36+
logger.info(f'Skipping {len(non_remindable_requests)} non-remindable learner credit requests.')
37+
logger.info(f'Queueing reminders for {len(remindable_requests)} learner credit requests.')
38+
39+
for req in remindable_requests:
40+
send_reminder_email_for_pending_learner_credit_request.delay(req.assignment.uuid)
41+
42+
return {
43+
'remindable_requests': list(remindable_requests),
44+
'non_remindable_requests': list(non_remindable_requests),
45+
}

enterprise_access/apps/subsidy_request/models.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
SubsidyTypeChoices
3030
)
3131
from enterprise_access.apps.subsidy_request.tasks import update_course_info_for_subsidy_request_task
32-
from enterprise_access.utils import localized_utcnow
32+
from enterprise_access.apps.subsidy_request.utils import get_action_choice, get_user_message_choice
33+
from enterprise_access.utils import format_traceback, localized_utcnow
3334

3435

3536
class SubsidyRequest(TimeStampedModel, SoftDeletableModel):
@@ -435,6 +436,26 @@ def approve(self, reviewer):
435436
self.reviewed_at = localized_utcnow()
436437
self.save()
437438

439+
def add_successful_reminded_action(self):
440+
"""
441+
Adds a successful "reminded" LearnerCreditRequestActions for this request.
442+
"""
443+
return self.actions.create(
444+
recent_action=get_action_choice(LearnerCreditAdditionalActionStates.REMINDED),
445+
status=get_user_message_choice(LearnerCreditAdditionalActionStates.REMINDED),
446+
)
447+
448+
def add_errored_reminded_action(self, exc):
449+
"""
450+
Adds an errored "reminded" LearnerCreditRequestActions for this request.
451+
"""
452+
return self.actions.create(
453+
recent_action=get_action_choice(LearnerCreditAdditionalActionStates.REMINDED),
454+
status=get_user_message_choice(LearnerCreditAdditionalActionStates.REMINDED),
455+
error_reason=LearnerCreditRequestActionErrorReasons.EMAIL_ERROR,
456+
traceback=format_traceback(exc),
457+
)
458+
438459
@classmethod
439460
def annotate_dynamic_fields_onto_queryset(cls, queryset):
440461
"""

0 commit comments

Comments
 (0)