Skip to content

Commit f037af3

Browse files
committed
feat: write a job to send email to admin for recent opened learner credit requests
1 parent 40780bc commit f037af3

File tree

6 files changed

+443
-42
lines changed

6 files changed

+443
-42
lines changed

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,8 +1801,7 @@ def test_create_success(self):
18011801
SubsidyRequestStates.EXPIRED,
18021802
SubsidyRequestStates.REVERSED,
18031803
)
1804-
@mock.patch(BNR_VIEW_PATH + '.send_learner_credit_bnr_admins_email_with_new_requests_task.delay')
1805-
def test_create_reuse_existing_request_success(self, reusable_state, mock_email_task):
1804+
def test_create_reuse_existing_request_success(self, reusable_state):
18061805
"""
18071806
Test that an existing request in reusable states (CANCELLED, EXPIRED, REVERSED)
18081807
gets reused instead of creating a new one.
@@ -1882,13 +1881,6 @@ def test_create_reuse_existing_request_success(self, reusable_state, mock_email_
18821881
assert action is not None
18831882
assert action.status == get_user_message_choice(SubsidyRequestStates.REQUESTED)
18841883

1885-
# Verify email notification task was called
1886-
mock_email_task.assert_called_once_with(
1887-
str(self.policy.uuid),
1888-
str(self.policy.learner_credit_request_config.uuid),
1889-
str(existing_request.enterprise_customer_uuid)
1890-
)
1891-
18921884
def test_overview_happy_path(self):
18931885
"""
18941886
Test the overview endpoint returns correct state counts.

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

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@
6565
SubsidyRequestCustomerConfiguration
6666
)
6767
from enterprise_access.apps.subsidy_request.tasks import (
68-
send_learner_credit_bnr_admins_email_with_new_requests_task,
6968
send_learner_credit_bnr_request_approve_task,
7069
send_reminder_email_for_pending_learner_credit_request
7170
)
@@ -895,12 +894,6 @@ def create(self, request, *args, **kwargs):
895894
recent_action=get_action_choice(SubsidyRequestStates.REQUESTED),
896895
status=get_user_message_choice(SubsidyRequestStates.REQUESTED),
897896
)
898-
# Trigger admin email notification with the latest request
899-
send_learner_credit_bnr_admins_email_with_new_requests_task.delay(
900-
str(policy.uuid),
901-
str(policy.learner_credit_request_config.uuid),
902-
str(existing_request.enterprise_customer_uuid)
903-
)
904897
response_data = serializers.LearnerCreditRequestSerializer(existing_request).data
905898
return Response(response_data, status=status.HTTP_200_OK)
906899
except Exception as exc: # pylint: disable=broad-except
@@ -938,13 +931,6 @@ def create(self, request, *args, **kwargs):
938931
recent_action=get_action_choice(SubsidyRequestStates.REQUESTED),
939932
status=get_user_message_choice(SubsidyRequestStates.REQUESTED),
940933
)
941-
942-
# Trigger admin email notification with the latest request
943-
send_learner_credit_bnr_admins_email_with_new_requests_task.delay(
944-
str(policy.uuid),
945-
str(policy.learner_credit_request_config.uuid),
946-
str(lcr.enterprise_customer_uuid)
947-
)
948934
except LearnerCreditRequest.DoesNotExist:
949935
logger.warning(f"LearnerCreditRequest {lcr_uuid} not found for action creation.")
950936

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""
2+
Management command to send daily Browse & Request learner credit digest emails to enterprise admins.
3+
4+
Simplified version: run once per day (e.g., via cron). It scans all BNR-enabled policies and
5+
queues a digest task for each policy that has one or more REQUESTED learner credit requests
6+
(open requests, regardless of creation date). Supports a --dry-run mode.
7+
"""
8+
import logging
9+
10+
from django.core.management.base import BaseCommand
11+
12+
from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy
13+
from enterprise_access.apps.subsidy_request.constants import SubsidyRequestStates
14+
from enterprise_access.apps.subsidy_request.models import LearnerCreditRequest
15+
from enterprise_access.apps.subsidy_request.tasks import send_learner_credit_bnr_admins_email_with_new_requests_task
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class Command(BaseCommand):
21+
"""Django management command to enqueue daily Browse & Request learner credit digest tasks.
22+
23+
Scans active, non-retired policies with an active learner credit request config and enqueues
24+
one Celery task per policy that has at least one open (REQUESTED) learner credit request.
25+
Supports an optional dry-run mode for visibility without enqueuing tasks.
26+
"""
27+
28+
help = ('Queue celery tasks that send daily digest emails for Browse & Request learner credit '
29+
'requests per BNR-enabled policy (simple mode).')
30+
31+
LOCK_KEY_TEMPLATE = 'bnr-lc-digest-{date}'
32+
LOCK_TIMEOUT_SECONDS = 2 * 60 * 60 # 2 hours
33+
34+
def add_arguments(self, parser): # noqa: D401 - intentionally left minimal
35+
parser.add_argument(
36+
'--dry-run',
37+
action='store_true',
38+
dest='dry_run',
39+
help='Show which tasks would be enqueued without sending them.'
40+
)
41+
42+
def handle(self, *args, **options):
43+
dry_run = options.get('dry_run')
44+
45+
policies_qs = SubsidyAccessPolicy.objects.filter(
46+
active=True,
47+
retired=False,
48+
learner_credit_request_config__isnull=False,
49+
learner_credit_request_config__active=True,
50+
).select_related('learner_credit_request_config')
51+
52+
total_policies = 0
53+
policies_with_requests = 0
54+
tasks_enqueued = 0
55+
56+
for policy in policies_qs.iterator():
57+
total_policies += 1
58+
config = policy.learner_credit_request_config
59+
if not config:
60+
continue
61+
62+
count_open = LearnerCreditRequest.objects.filter(
63+
learner_credit_request_config=config,
64+
enterprise_customer_uuid=policy.enterprise_customer_uuid,
65+
state=SubsidyRequestStates.REQUESTED,
66+
).count()
67+
if count_open == 0:
68+
continue
69+
70+
policies_with_requests += 1
71+
if dry_run:
72+
logger.info('[DRY RUN] Policy %s enterprise %s would enqueue digest task (%s open requests).',
73+
policy.uuid, policy.enterprise_customer_uuid, count_open)
74+
continue
75+
76+
logger.info(
77+
'Policy %s enterprise %s has %s open learner credit requests. Enqueuing digest task.',
78+
policy.uuid, policy.enterprise_customer_uuid, count_open
79+
)
80+
send_learner_credit_bnr_admins_email_with_new_requests_task.delay(
81+
str(policy.uuid),
82+
str(config.uuid),
83+
str(policy.enterprise_customer_uuid),
84+
)
85+
tasks_enqueued += 1
86+
87+
summary = (
88+
f"BNR daily digest summary: scanned={total_policies} policies, "
89+
f"with_requests={policies_with_requests}, tasks_enqueued={tasks_enqueued}, dry_run={dry_run}"
90+
)
91+
logger.info(summary)
92+
self.stdout.write(self.style.SUCCESS(summary))
93+
return 0
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Tests for send_learner_credit_bnr_daily_digest management command.
2+
3+
Covers command behavior for enqueuing tasks when there are open (REQUESTED) requests.
4+
"""
5+
6+
from datetime import timedelta
7+
from unittest import mock
8+
from uuid import uuid4
9+
10+
import pytest
11+
from django.core.management import call_command
12+
from django.utils import timezone
13+
14+
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
15+
PerLearnerSpendCapLearnerCreditAccessPolicyFactory
16+
)
17+
from enterprise_access.apps.subsidy_request.constants import SubsidyRequestStates
18+
from enterprise_access.apps.subsidy_request.tests.factories import (
19+
LearnerCreditRequestConfigurationFactory,
20+
LearnerCreditRequestFactory
21+
)
22+
23+
24+
@pytest.mark.django_db
25+
class TestSendLearnerCreditBNRDailyDigestCommand:
26+
"""Tests enqueuing behavior for the BNR daily digest management command."""
27+
command_name = "send_learner_credit_bnr_daily_digest"
28+
29+
def _make_policy_with_config(
30+
self,
31+
*,
32+
active=True,
33+
retired=False,
34+
config_active=True,
35+
enterprise_uuid=None
36+
):
37+
"""Helper to create a policy and its learner credit request config for test setups."""
38+
enterprise_uuid = enterprise_uuid or uuid4()
39+
config = LearnerCreditRequestConfigurationFactory(
40+
active=config_active
41+
)
42+
policy = PerLearnerSpendCapLearnerCreditAccessPolicyFactory(
43+
active=active,
44+
retired=retired,
45+
learner_credit_request_config=config,
46+
enterprise_customer_uuid=enterprise_uuid,
47+
)
48+
return policy, config
49+
50+
@mock.patch(
51+
"enterprise_access.apps.subsidy_request.management.commands."
52+
"send_learner_credit_bnr_daily_digest."
53+
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
54+
)
55+
def test_no_eligible_policies(self, mock_delay):
56+
# Inactive policy
57+
self._make_policy_with_config(active=False)
58+
# Retired policy
59+
self._make_policy_with_config(retired=True)
60+
# No config
61+
PerLearnerSpendCapLearnerCreditAccessPolicyFactory()
62+
# Inactive config
63+
self._make_policy_with_config(config_active=False)
64+
65+
call_command(self.command_name)
66+
mock_delay.assert_not_called()
67+
68+
@mock.patch(
69+
"enterprise_access.apps.subsidy_request.management.commands."
70+
"send_learner_credit_bnr_daily_digest."
71+
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
72+
)
73+
def test_eligible_policy_no_open_requests(self, mock_delay):
74+
policy, config = self._make_policy_with_config()
75+
LearnerCreditRequestFactory(
76+
enterprise_customer_uuid=policy.enterprise_customer_uuid,
77+
learner_credit_request_config=config,
78+
state=SubsidyRequestStates.APPROVED,
79+
)
80+
call_command(self.command_name)
81+
mock_delay.assert_not_called()
82+
83+
@mock.patch(
84+
"enterprise_access.apps.subsidy_request.management.commands."
85+
"send_learner_credit_bnr_daily_digest."
86+
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
87+
)
88+
def test_enqueues_for_open_request(self, mock_delay):
89+
policy, config = self._make_policy_with_config()
90+
LearnerCreditRequestFactory(
91+
enterprise_customer_uuid=policy.enterprise_customer_uuid,
92+
learner_credit_request_config=config,
93+
state=SubsidyRequestStates.REQUESTED,
94+
)
95+
call_command(self.command_name)
96+
assert mock_delay.call_count == 1
97+
args = mock_delay.call_args[0]
98+
assert str(policy.uuid) == args[0]
99+
assert str(config.uuid) == args[1]
100+
assert str(policy.enterprise_customer_uuid) == args[2]
101+
102+
@mock.patch(
103+
"enterprise_access.apps.subsidy_request.management.commands."
104+
"send_learner_credit_bnr_daily_digest."
105+
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
106+
)
107+
def test_old_open_request_still_enqueues(self, mock_delay):
108+
policy, config = self._make_policy_with_config()
109+
req = LearnerCreditRequestFactory(
110+
enterprise_customer_uuid=policy.enterprise_customer_uuid,
111+
learner_credit_request_config=config,
112+
state=SubsidyRequestStates.REQUESTED,
113+
)
114+
# Make it "old" (yesterday)
115+
req.created = timezone.now() - timedelta(days=7)
116+
req.save(update_fields=["created"])
117+
118+
call_command(self.command_name)
119+
assert mock_delay.call_count == 1
120+
121+
@mock.patch(
122+
"enterprise_access.apps.subsidy_request.management.commands."
123+
"send_learner_credit_bnr_daily_digest."
124+
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
125+
)
126+
def test_multiple_policies_mixed(self, mock_delay):
127+
policy_a, config_a = self._make_policy_with_config()
128+
LearnerCreditRequestFactory(
129+
enterprise_customer_uuid=policy_a.enterprise_customer_uuid,
130+
learner_credit_request_config=config_a,
131+
state=SubsidyRequestStates.REQUESTED,
132+
)
133+
134+
self._make_policy_with_config()
135+
136+
policy_c, config_c = self._make_policy_with_config()
137+
LearnerCreditRequestFactory(
138+
enterprise_customer_uuid=policy_c.enterprise_customer_uuid,
139+
learner_credit_request_config=config_c,
140+
state=SubsidyRequestStates.REQUESTED,
141+
)
142+
143+
call_command(self.command_name)
144+
145+
assert mock_delay.call_count == 2
146+
calls = [
147+
mock.call(
148+
str(policy_a.uuid),
149+
str(config_a.uuid),
150+
str(policy_a.enterprise_customer_uuid),
151+
),
152+
mock.call(
153+
str(policy_c.uuid),
154+
str(config_c.uuid),
155+
str(policy_c.enterprise_customer_uuid),
156+
),
157+
]
158+
actual = [c.args for c in mock_delay.call_args_list]
159+
assert sorted(calls) == sorted([mock.call(*args) for args in actual])

0 commit comments

Comments
 (0)