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
Original file line number Diff line number Diff line change
Expand Up @@ -1801,8 +1801,7 @@ def test_create_success(self):
SubsidyRequestStates.EXPIRED,
SubsidyRequestStates.REVERSED,
)
@mock.patch(BNR_VIEW_PATH + '.send_learner_credit_bnr_admins_email_with_new_requests_task.delay')
def test_create_reuse_existing_request_success(self, reusable_state, mock_email_task):
def test_create_reuse_existing_request_success(self, reusable_state):
"""
Test that an existing request in reusable states (CANCELLED, EXPIRED, REVERSED)
gets reused instead of creating a new one.
Expand Down Expand Up @@ -1882,13 +1881,6 @@ def test_create_reuse_existing_request_success(self, reusable_state, mock_email_
assert action is not None
assert action.status == get_user_message_choice(SubsidyRequestStates.REQUESTED)

# Verify email notification task was called
mock_email_task.assert_called_once_with(
str(self.policy.uuid),
str(self.policy.learner_credit_request_config.uuid),
str(existing_request.enterprise_customer_uuid)
)

def test_overview_happy_path(self):
"""
Test the overview endpoint returns correct state counts.
Expand Down
14 changes: 0 additions & 14 deletions enterprise_access/apps/api/v1/views/browse_and_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
SubsidyRequestCustomerConfiguration
)
from enterprise_access.apps.subsidy_request.tasks import (
send_learner_credit_bnr_admins_email_with_new_requests_task,
send_learner_credit_bnr_request_approve_task,
send_reminder_email_for_pending_learner_credit_request
)
Expand Down Expand Up @@ -895,12 +894,6 @@ def create(self, request, *args, **kwargs):
recent_action=get_action_choice(SubsidyRequestStates.REQUESTED),
status=get_user_message_choice(SubsidyRequestStates.REQUESTED),
)
# Trigger admin email notification with the latest request
send_learner_credit_bnr_admins_email_with_new_requests_task.delay(
str(policy.uuid),
str(policy.learner_credit_request_config.uuid),
str(existing_request.enterprise_customer_uuid)
)
response_data = serializers.LearnerCreditRequestSerializer(existing_request).data
return Response(response_data, status=status.HTTP_200_OK)
except Exception as exc: # pylint: disable=broad-except
Expand Down Expand Up @@ -938,13 +931,6 @@ def create(self, request, *args, **kwargs):
recent_action=get_action_choice(SubsidyRequestStates.REQUESTED),
status=get_user_message_choice(SubsidyRequestStates.REQUESTED),
)

# Trigger admin email notification with the latest request
send_learner_credit_bnr_admins_email_with_new_requests_task.delay(
str(policy.uuid),
str(policy.learner_credit_request_config.uuid),
str(lcr.enterprise_customer_uuid)
)
except LearnerCreditRequest.DoesNotExist:
logger.warning(f"LearnerCreditRequest {lcr_uuid} not found for action creation.")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
Management command to send daily Browse & Request learner credit digest emails to enterprise admins.

Simplified version: run once per day (e.g., via cron). It scans all BNR-enabled policies and
queues a digest task for each policy that has one or more REQUESTED learner credit requests
(open requests, regardless of creation date). Supports a --dry-run mode.
"""
import logging

from django.core.management.base import BaseCommand

from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy
from enterprise_access.apps.subsidy_request.constants import SubsidyRequestStates
from enterprise_access.apps.subsidy_request.models import LearnerCreditRequest
from enterprise_access.apps.subsidy_request.tasks import send_learner_credit_bnr_admins_email_with_new_requests_task

logger = logging.getLogger(__name__)


class Command(BaseCommand):
"""Django management command to enqueue daily Browse & Request learner credit digest tasks.

Scans active, non-retired policies with an active learner credit request config and enqueues
one Celery task per policy that has at least one open (REQUESTED) learner credit request.
Supports an optional dry-run mode for visibility without enqueuing tasks.
"""

help = ('Queue celery tasks that send daily digest emails for Browse & Request learner credit '
'requests per BNR-enabled policy (simple mode).')

LOCK_KEY_TEMPLATE = 'bnr-lc-digest-{date}'
LOCK_TIMEOUT_SECONDS = 2 * 60 * 60 # 2 hours

def add_arguments(self, parser): # noqa: D401 - intentionally left minimal
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
help='Show which tasks would be enqueued without sending them.'
)

def handle(self, *args, **options):
dry_run = options.get('dry_run')

policies_qs = SubsidyAccessPolicy.objects.filter(
active=True,
retired=False,
learner_credit_request_config__isnull=False,
learner_credit_request_config__active=True,
).select_related('learner_credit_request_config')

total_policies = 0
policies_with_requests = 0
tasks_enqueued = 0

for policy in policies_qs.iterator():
total_policies += 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be handled outside the loop with policies_qs.count()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

policies_qs.count() runs a separate SELECT COUNT(*) query, which is why I am not using it. [Reason for current approach] We already iterate policies_qs to do the per-policy check, incrementing total_policies inside that loop costs nothing extra.

config = policy.learner_credit_request_config
if not config:
continue

num_open_requests = LearnerCreditRequest.objects.filter(
learner_credit_request_config=config,
enterprise_customer_uuid=policy.enterprise_customer_uuid,
state=SubsidyRequestStates.REQUESTED,
).count()
if num_open_requests == 0:
continue

policies_with_requests += 1
if dry_run:
logger.info('[DRY RUN] Policy %s enterprise %s would enqueue digest task (%s open requests).',
policy.uuid, policy.enterprise_customer_uuid, num_open_requests)
continue

logger.info(
'Policy %s enterprise %s has %s open learner credit requests. Enqueuing digest task.',
policy.uuid, policy.enterprise_customer_uuid, num_open_requests
)
send_learner_credit_bnr_admins_email_with_new_requests_task.delay(
str(policy.uuid),
str(config.uuid),
str(policy.enterprise_customer_uuid),
)
tasks_enqueued += 1

summary = (
f"BNR daily digest summary: scanned={total_policies} policies, "
f"with_requests={policies_with_requests}, tasks_enqueued={tasks_enqueued}, dry_run={dry_run}"
)
logger.info(summary)
self.stdout.write(self.style.SUCCESS(summary))
return 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Tests for send_learner_credit_bnr_daily_digest management command.

Covers command behavior for enqueuing tasks when there are open (REQUESTED) requests.
"""

from datetime import timedelta
from unittest import mock
from uuid import uuid4

import pytest
from django.core.management import call_command
from django.utils import timezone

from enterprise_access.apps.subsidy_access_policy.tests.factories import (
PerLearnerSpendCapLearnerCreditAccessPolicyFactory
)
from enterprise_access.apps.subsidy_request.constants import SubsidyRequestStates
from enterprise_access.apps.subsidy_request.tests.factories import (
LearnerCreditRequestConfigurationFactory,
LearnerCreditRequestFactory
)


@pytest.mark.django_db
class TestSendLearnerCreditBNRDailyDigestCommand:
"""Tests enqueuing behavior for the BNR daily digest management command."""
command_name = "send_learner_credit_bnr_daily_digest"

def _make_policy_with_config(
self,
*,
active=True,
retired=False,
config_active=True,
enterprise_uuid=None
):
"""Helper to create a policy and its learner credit request config for test setups."""
enterprise_uuid = enterprise_uuid or uuid4()
config = LearnerCreditRequestConfigurationFactory(
active=config_active
)
policy = PerLearnerSpendCapLearnerCreditAccessPolicyFactory(
active=active,
retired=retired,
learner_credit_request_config=config,
enterprise_customer_uuid=enterprise_uuid,
)
return policy, config

@mock.patch(
"enterprise_access.apps.subsidy_request.management.commands."
"send_learner_credit_bnr_daily_digest."
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
)
def test_no_eligible_policies(self, mock_delay):
# Inactive policy
self._make_policy_with_config(active=False)
# Retired policy
self._make_policy_with_config(retired=True)
# No config
PerLearnerSpendCapLearnerCreditAccessPolicyFactory()
# Inactive config
self._make_policy_with_config(config_active=False)

call_command(self.command_name)
mock_delay.assert_not_called()

@mock.patch(
"enterprise_access.apps.subsidy_request.management.commands."
"send_learner_credit_bnr_daily_digest."
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
)
def test_eligible_policy_no_open_requests(self, mock_delay):
policy, config = self._make_policy_with_config()
LearnerCreditRequestFactory(
enterprise_customer_uuid=policy.enterprise_customer_uuid,
learner_credit_request_config=config,
state=SubsidyRequestStates.APPROVED,
)
call_command(self.command_name)
mock_delay.assert_not_called()

@mock.patch(
"enterprise_access.apps.subsidy_request.management.commands."
"send_learner_credit_bnr_daily_digest."
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
)
def test_enqueues_for_open_request(self, mock_delay):
policy, config = self._make_policy_with_config()
LearnerCreditRequestFactory(
enterprise_customer_uuid=policy.enterprise_customer_uuid,
learner_credit_request_config=config,
state=SubsidyRequestStates.REQUESTED,
)
call_command(self.command_name)
assert mock_delay.call_count == 1
args = mock_delay.call_args[0]
assert str(policy.uuid) == args[0]
assert str(config.uuid) == args[1]
assert str(policy.enterprise_customer_uuid) == args[2]

@mock.patch(
"enterprise_access.apps.subsidy_request.management.commands."
"send_learner_credit_bnr_daily_digest."
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
)
def test_old_open_request_still_enqueues(self, mock_delay):
policy, config = self._make_policy_with_config()
req = LearnerCreditRequestFactory(
enterprise_customer_uuid=policy.enterprise_customer_uuid,
learner_credit_request_config=config,
state=SubsidyRequestStates.REQUESTED,
)
# Make it "old" (yesterday)
req.created = timezone.now() - timedelta(days=7)
req.save(update_fields=["created"])

call_command(self.command_name)
assert mock_delay.call_count == 1

@mock.patch(
"enterprise_access.apps.subsidy_request.management.commands."
"send_learner_credit_bnr_daily_digest."
"send_learner_credit_bnr_admins_email_with_new_requests_task.delay"
)
def test_multiple_policies_mixed(self, mock_delay):
policy_a, config_a = self._make_policy_with_config()
LearnerCreditRequestFactory(
enterprise_customer_uuid=policy_a.enterprise_customer_uuid,
learner_credit_request_config=config_a,
state=SubsidyRequestStates.REQUESTED,
)

self._make_policy_with_config()

policy_c, config_c = self._make_policy_with_config()
LearnerCreditRequestFactory(
enterprise_customer_uuid=policy_c.enterprise_customer_uuid,
learner_credit_request_config=config_c,
state=SubsidyRequestStates.REQUESTED,
)

call_command(self.command_name)

assert mock_delay.call_count == 2
calls = [
mock.call(
str(policy_a.uuid),
str(config_a.uuid),
str(policy_a.enterprise_customer_uuid),
),
mock.call(
str(policy_c.uuid),
str(config_c.uuid),
str(policy_c.enterprise_customer_uuid),
),
]
actual = [c.args for c in mock_delay.call_args_list]
assert sorted(calls) == sorted([mock.call(*args) for args in actual])
Loading