From 0dfa633dfad2b811b97aeb8ca195f831e9ad43b8 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Wed, 28 Oct 2026 12:59:35 +0100 Subject: [PATCH 1/2] =?UTF-8?q?remove=20test=5Fmigration=20=E2=80=93=20lat?= =?UTF-8?q?er=20this=20can=20fail=20due=20to=20squash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dref/test_final_report_alerts.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/dref/test_final_report_alerts.py b/dref/test_final_report_alerts.py index 7071e646e..4d368767b 100644 --- a/dref/test_final_report_alerts.py +++ b/dref/test_final_report_alerts.py @@ -142,25 +142,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) From 60515db169f64365ccc5d5fd4b1d304ed34de10b Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Wed, 28 Oct 2026 15:50:20 +0100 Subject: [PATCH 2/2] dref: get latest one per appeal_code for alerting --- .../commands/dref_final_report_alert.py | 13 ++- dref/test_final_report_alerts.py | 94 ++++++++++++++++++- dref/utils.py | 17 ++++ 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/dref/management/commands/dref_final_report_alert.py b/dref/management/commands/dref_final_report_alert.py index cb7c5a62a..efef8d383 100644 --- a/dref/management/commands/dref_final_report_alert.py +++ b/dref/management/commands/dref_final_report_alert.py @@ -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" @@ -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(): @@ -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(): @@ -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(): @@ -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(): diff --git a/dref/test_final_report_alerts.py b/dref/test_final_report_alerts.py index 4d368767b..22bdbfecf 100644 --- a/dref/test_final_report_alerts.py +++ b/dref/test_final_report_alerts.py @@ -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 @@ -441,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): diff --git a/dref/utils.py b/dref/utils.py index 5622de0aa..e65ad0e18 100644 --- a/dref/utils.py +++ b/dref/utils.py @@ -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 @@ -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)