Skip to content
Merged
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
13 changes: 8 additions & 5 deletions dref/management/commands/dref_final_report_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
send_final_report_recurring_overdue_email,
send_final_report_reminder_email,
)
from dref.utils import get_final_report_operation_end_date
from dref.utils import (
get_final_report_operation_end_date,
get_latest_dref_per_appeal_code_filter,
)
from main.sentry import SentryMonitor

CRON_NAME = "dref_final_report_alert"
Expand Down Expand Up @@ -66,7 +69,7 @@ def send_implementation_end_alerts(self, today):
queryset = Dref.objects.filter(
type_of_dref__in=IN_SCOPE_DREF_TYPES,
final_report_implementation_end_alert_sent_at__isnull=True,
).filter(NOT_SUBMITTED_FILTER)
).filter(NOT_SUBMITTED_FILTER, get_latest_dref_per_appeal_code_filter(IN_SCOPE_DREF_TYPES))

count = 0
for instance in queryset.iterator():
Expand All @@ -90,7 +93,7 @@ def send_reminder_alerts(self, today):
queryset = Dref.objects.filter(
type_of_dref__in=IN_SCOPE_DREF_TYPES,
final_report_reminder_alert_sent_at__isnull=True,
).filter(NOT_SUBMITTED_FILTER)
).filter(NOT_SUBMITTED_FILTER, get_latest_dref_per_appeal_code_filter(IN_SCOPE_DREF_TYPES))

count = 0
for instance in queryset.iterator():
Expand All @@ -113,7 +116,7 @@ def send_overdue_alerts(self, today):
type_of_dref__in=IN_SCOPE_DREF_TYPES,
final_report_due_date__lte=today,
final_report_overdue_alert_sent_at__isnull=True,
).filter(NOT_SUBMITTED_FILTER)
).filter(NOT_SUBMITTED_FILTER, get_latest_dref_per_appeal_code_filter(IN_SCOPE_DREF_TYPES))

count = 0
for instance in queryset.iterator():
Expand All @@ -132,7 +135,7 @@ def send_recurring_overdue_alerts(self, today):
final_report_overdue_alert_sent_at__isnull=False,
final_report_due_date__isnull=False,
final_report_due_date__lt=today,
).filter(NOT_SUBMITTED_FILTER)
).filter(NOT_SUBMITTED_FILTER, get_latest_dref_per_appeal_code_filter(IN_SCOPE_DREF_TYPES))

count = 0
for instance in queryset.iterator():
Expand Down
113 changes: 92 additions & 21 deletions dref/test_final_report_alerts.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from datetime import date, timedelta
from importlib import import_module
from unittest import mock

from dateutil.relativedelta import relativedelta
from django.apps import apps
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
Expand Down Expand Up @@ -142,25 +140,6 @@ def test_due_date_falls_back_when_approval_is_removed(self):
self.dref.refresh_from_db()
self.assertEqual(self.dref.final_report_due_date, date(2026, 4, 30))

def test_migration_refreshes_existing_deadlines_without_resetting_alerts(self):
self._create_update()
fallback_dref = DrefFactory.create(appeal_code="MDRXX002", end_date=date(2026, 1, 31))
sent_at = timezone.now()
Dref.objects.filter(pk__in=[self.dref.pk, fallback_dref.pk]).update(
final_report_due_date=None,
final_report_overdue_alert_sent_at=sent_at,
)
migration = import_module("dref.migrations.0094_refresh_final_report_due_dates")

migration.refresh_final_report_due_dates(apps, mock.Mock(connection=mock.Mock(alias="default")))

self.dref.refresh_from_db()
fallback_dref.refresh_from_db()
self.assertEqual(self.dref.final_report_due_date, date(2026, 8, 31))
self.assertEqual(fallback_dref.final_report_due_date, date(2026, 4, 30))
self.assertEqual(self.dref.final_report_overdue_alert_sent_at, sent_at)
self.assertEqual(fallback_dref.final_report_overdue_alert_sent_at, sent_at)

def test_due_date_computed_on_save(self):
today = timezone.now().date()
dref = DrefFactory.create(end_date=today)
Expand Down Expand Up @@ -460,6 +439,98 @@ def test_recurring_overdue_alert_skipped_when_final_report_approved(

mock_recurring.assert_not_called()

def test_only_latest_dref_per_appeal_code_gets_alerts(
self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring
):
# Several Dref rows share one appeal_code - only the one with the max id may trigger alerts.
self._create_dref(appeal_code="MDRXX001", end_date=self.today)
self._create_dref(appeal_code="MDRXX001", end_date=self.today)
latest = self._create_dref(appeal_code="MDRXX001", end_date=self.today)

call_command("dref_final_report_alert")

mock_implementation_end.assert_called_once_with(latest.id)

def test_only_latest_dref_per_appeal_code_gets_overdue_and_recurring_alerts(
self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring
):
end_date = self.today - relativedelta(months=DREF_FINAL_REPORT_DUE_MONTHS)
self._create_dref(appeal_code="MDRXX001", end_date=end_date)
latest_overdue = self._create_dref(appeal_code="MDRXX001", end_date=end_date)
last_sent = timezone.now() - timedelta(days=35)
recurring_end_date = self.today - relativedelta(months=self.RECURRING_DUE_MONTHS_AGO)
recurring_kwargs = {
"end_date": recurring_end_date,
"final_report_overdue_alert_sent_at": last_sent,
"final_report_recurring_overdue_alert_sent_at": last_sent,
}
self._create_dref(appeal_code="MDRXX002", **recurring_kwargs)
latest_recurring = self._create_dref(appeal_code="MDRXX002", **recurring_kwargs)

call_command("dref_final_report_alert")

mock_overdue.assert_called_once_with(latest_overdue.id)
mock_recurring.assert_called_once_with(latest_recurring.id)

def test_older_dref_is_skipped_even_if_latest_dref_has_approved_final_report(
self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring
):
self._create_dref(appeal_code="MDRXX001", end_date=self.today)
latest = self._create_dref(appeal_code="MDRXX001", end_date=self.today)
DrefFinalReportFactory.create(dref=latest, status=Dref.Status.APPROVED)

call_command("dref_final_report_alert")

mock_implementation_end.assert_not_called()

def test_older_dref_is_skipped_even_if_latest_dref_already_sent_the_alert(
self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring
):
self._create_dref(appeal_code="MDRXX001", end_date=self.today)
self._create_dref(
appeal_code="MDRXX001",
end_date=self.today,
final_report_implementation_end_alert_sent_at=timezone.now(),
)

call_command("dref_final_report_alert")

mock_implementation_end.assert_not_called()

def test_each_appeal_code_gets_its_own_alert(self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring):
first = self._create_dref(appeal_code="MDRXX001", end_date=self.today)
second = self._create_dref(appeal_code="MDRXX002", end_date=self.today)

call_command("dref_final_report_alert")

self.assertEqual(
sorted(call.args[0] for call in mock_implementation_end.call_args_list),
sorted([first.id, second.id]),
)

def test_drefs_without_appeal_code_are_not_grouped(
self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring
):
first = self._create_dref(appeal_code=None, end_date=self.today)
second = self._create_dref(appeal_code="", end_date=self.today)

call_command("dref_final_report_alert")

self.assertEqual(
sorted(call.args[0] for call in mock_implementation_end.call_args_list),
sorted([first.id, second.id]),
)

def test_out_of_scope_dref_with_same_appeal_code_does_not_hide_the_in_scope_one(
self, mock_implementation_end, mock_reminder, mock_overdue, mock_recurring
):
in_scope = self._create_dref(appeal_code="MDRXX001", end_date=self.today)
self._create_dref(appeal_code="MDRXX001", end_date=self.today, type_of_dref=Dref.DrefType.LOAN)

call_command("dref_final_report_alert")

mock_implementation_end.assert_called_once_with(in_scope.id)


class GetFinalReportAlertRecipientsTest(TestCase):
def setUp(self):
Expand Down
17 changes: 17 additions & 0 deletions dref/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import models
from django.db.models import Max, Q
from django.utils import timezone

from api.models import Appeal, AppealType, Event
Expand Down Expand Up @@ -55,6 +56,22 @@ def get_latest_approved_operational_update(dref: Dref) -> Optional[DrefOperation
)


def get_latest_dref_per_appeal_code_filter(dref_types) -> Q:
"""Keep only the latest created (max id) Dref of each appeal_code, so that several Dref rows
sharing one appeal_code (the same operation) don't each trigger their own final report alerts.
The latest is picked among the given Dref types, regardless of the Final Report status.
Drefs without an appeal_code can't be grouped, so they are all kept."""
latest_ids = (
Dref.objects.filter(type_of_dref__in=dref_types, appeal_code__isnull=False)
.exclude(appeal_code="")
.order_by()
.values("appeal_code")
.annotate(latest_id=Max("id"))
.values("latest_id")
)
return Q(appeal_code__isnull=True) | Q(appeal_code="") | Q(id__in=latest_ids)


def get_final_report_operation_end_date(dref: Dref):
"""Use the latest approved update's end date, falling back to the application."""
latest_operational_update = get_latest_approved_operational_update(dref)
Expand Down
Loading