Skip to content

Commit d64ee69

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

File tree

6 files changed

+153
-49
lines changed

6 files changed

+153
-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/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_query_params,
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
"""

enterprise_access/apps/subsidy_request/tasks.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def log_errored_action(self, learner_credit_request, exc):
8484
f'Enterprise ID: {learner_credit_request.enterprise_customer_uuid}, '
8585
f'Exception: {exc}'
8686
)
87+
learner_credit_request.add_errored_reminded_action(exc)
8788

8889

8990
# pylint: disable=abstract-method
@@ -396,6 +397,9 @@ def send_reminder_email_for_pending_learner_credit_request(assignment_uuid):
396397
braze_trigger_properties,
397398
campaign_uuid,
398399
)
400+
401+
if hasattr(assignment, 'credit_request') and assignment.credit_request:
402+
assignment.credit_request.add_successful_reminded_action()
399403
logger.info(f'Sent braze campaign reminder uuid={campaign_uuid} message for assignment {assignment}')
400404

401405

0 commit comments

Comments
 (0)