diff --git a/api/cases/celery_tasks.py b/api/cases/celery_tasks.py index 168d140222..a667619f0d 100644 --- a/api/cases/celery_tasks.py +++ b/api/cases/celery_tasks.py @@ -10,11 +10,12 @@ from pytz import timezone as tz from api.cases.enums import CaseTypeSubTypeEnum -from api.cases.models import Case, CaseAssignmentSLA, CaseQueue, DepartmentSLA -from api.cases.models import EcjuQuery +from api.cases.models import Case, CaseAssignmentSLA, CaseQueue, DepartmentSLA, EcjuQuery from api.common.dates import is_weekend, is_bank_holiday from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus +from api.cases.notify import notify_exporter_ecju_query_chaser + # DST safe version of midnight SLA_UPDATE_CUTOFF_TIME = time(18, 0, 0) @@ -156,3 +157,75 @@ def update_cases_sla(): logger.info("SLA Update Not Performed. Non-working day") return False + + +WORKING_DAYS_ECJU_QUERY_CHASER_REMINDER = 15 +WORKING_DAYS_APPLICATION = 20 + + +@shared_task( + autoretry_for=(Exception,), + max_retries=MAX_ATTEMPTS, + retry_backoff=RETRY_BACKOFF, +) +def schedule_all_ecju_query_chaser_emails(): + """ + Sends an ECJU 15 working days reminder + Runs as a background task daily at a given time. + Doesn't run on non-working days (bank-holidays & weekends) + """ + logger.info("Sending all ECJU query chaser emails started") + + try: + ecju_query_reminders = [] + ecju_queries = EcjuQuery.objects.filter( + Q(is_query_closed=False) & Q(chaser_email_sent_on__isnull=True) & Q(case__status__is_terminal=False) + ) + + for ecju_query in ecju_queries: + if ( + ecju_query.open_working_days >= WORKING_DAYS_ECJU_QUERY_CHASER_REMINDER + and ecju_query.open_working_days <= WORKING_DAYS_APPLICATION + ): + ecju_query_reminders.append(ecju_query.id) + + for ecju_query_id in ecju_query_reminders: + # Now lets loop round and send the notifications + send_ecju_query_chaser_email.delay(ecju_query_id) + + logger.info("Sending all ECJU query chaser emails started finished") + + except Exception as e: # noqa + logger.error(e) + raise e + + +@shared_task( + autoretry_for=(Exception,), + max_retries=MAX_ATTEMPTS, + retry_backoff=RETRY_BACKOFF, +) +def send_ecju_query_chaser_email(ecju_query_id): + """ + Sends an ecju query chaser email based on a case + Call back is to mark the relevent queries as chaser sent + """ + logger.info("Sending ECJU Query chaser emails for ecju_query_id %s started", ecju_query_id) + try: + notify_exporter_ecju_query_chaser(ecju_query_id, callback=mark_ecju_queries_as_sent.si(ecju_query_id)) + logger.info("Sending ECJU Query chaser email for ecju_query_id %s finished", ecju_query_id) + except Exception as e: # noqa + logger.error(e) + raise e + + +@shared_task +def mark_ecju_queries_as_sent(ecju_query_id): + """ + Used as a call back method to set chaser_email_sent once a chaser email has been sent + """ + logger.info("Mark ECJU queries with chaser_email_sent as true for ecju_query_ids (%s) ", ecju_query_id) + ecju_query = EcjuQuery.objects.get(chaser_email_sent_on__isnull=True, id=ecju_query_id) + ecju_query.chaser_email_sent_on = timezone.datetime.now() + # Save base so we don't impact any over fields + ecju_query.save_base() diff --git a/api/cases/migrations/0063_ecjuquery_chaser_email_sent_on.py b/api/cases/migrations/0063_ecjuquery_chaser_email_sent_on.py new file mode 100644 index 0000000000..246b07e13b --- /dev/null +++ b/api/cases/migrations/0063_ecjuquery_chaser_email_sent_on.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-02-07 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0062_alter_ecjuquery_response'), + ] + + operations = [ + migrations.AddField( + model_name='ecjuquery', + name='chaser_email_sent_on', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/api/cases/models.py b/api/cases/models.py index d54dc7b9c2..c278034e1d 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -621,6 +621,7 @@ class EcjuQuery(TimestampableModel): query_type = models.CharField( choices=ECJUQueryType.choices, max_length=50, default=ECJUQueryType.ECJU, null=False, blank=False ) + chaser_email_sent_on = models.DateTimeField(blank=True, null=True) @queryable_property def is_query_closed(self): @@ -636,7 +637,7 @@ def is_manually_closed(self): # This method allows the above propery to be used in filtering objects. Similar to db fields. @is_query_closed.filter(lookups=("exact",)) def is_query_closed(self, lookup, value): - return ~Q(responded_at__isnull=value) + return ~Q(responded_by_user__isnull=value) @property def open_working_days(self): diff --git a/api/cases/notify.py b/api/cases/notify.py index 72c7242dab..9df8eb48ce 100644 --- a/api/cases/notify.py +++ b/api/cases/notify.py @@ -1,10 +1,11 @@ from django.db.models import F from api.core.helpers import get_exporter_frontend_url -from api.cases.models import Case +from api.cases.models import Case, EcjuQuery from gov_notify.enums import TemplateType from gov_notify.payloads import ( ExporterECJUQuery, + ExporterECJUQueryChaser, ExporterLicenceIssued, ExporterLicenceRefused, ExporterNoLicenceRequired, @@ -104,11 +105,33 @@ def notify_exporter_ecju_query(case_pk): ) +def notify_exporter_ecju_query_chaser(ecju_query_id, callback): + APPLICATION_WORKING_DAYS = 20 + ecju_query = EcjuQuery.objects.get(id=ecju_query_id) + + exporter_frontend_ecju_queries_url = get_exporter_frontend_url(f"/applications/{ecju_query.case_id}/ecju-queries/") + + _notify_exporter_ecju_query_chaser( + ecju_query.case.submitted_by.email, + { + "case_reference": ecju_query.case.reference_code, + "exporter_frontend_ecju_queries_url": exporter_frontend_ecju_queries_url, + "remaining_days": APPLICATION_WORKING_DAYS - ecju_query.open_working_days, + }, + callback, + ) + + def _notify_exporter_ecju_query(email, data): payload = ExporterECJUQuery(**data) send_email(email, TemplateType.EXPORTER_ECJU_QUERY, payload) +def _notify_exporter_ecju_query_chaser(email, data, callback): + payload = ExporterECJUQueryChaser(**data) + send_email(email, TemplateType.EXPORTER_ECJU_QUERY_CHASER, payload, callback) + + def _notify_exporter_no_licence_required(email, data): payload = ExporterNoLicenceRequired(**data) send_email(email, TemplateType.EXPORTER_NO_LICENCE_REQUIRED, payload) diff --git a/api/cases/tests/test_case_ecju_queries.py b/api/cases/tests/test_case_ecju_queries.py index 07d40d6137..e06603432e 100644 --- a/api/cases/tests/test_case_ecju_queries.py +++ b/api/cases/tests/test_case_ecju_queries.py @@ -1,11 +1,20 @@ +from api.conf import settings +import pytest +import datetime from unittest import mock from datetime import timedelta from django.urls import reverse from django.utils import timezone +from api.cases.celery_tasks import ( + schedule_all_ecju_query_chaser_emails, + send_ecju_query_chaser_email, + mark_ecju_queries_as_sent, +) from api.cases.tests.factories import EcjuQueryFactory from api.users.models import BaseNotification from faker import Faker +from gov_notify.payloads import ExporterECJUQueryChaser from parameterized import parameterized from rest_framework import status from freezegun import freeze_time @@ -23,6 +32,7 @@ from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from test_helpers.clients import DataTestClient from api.users.tests.factories import ExporterUserFactory +from gov_notify.enums import TemplateType faker = Faker() @@ -681,3 +691,197 @@ def test_exporter_cannot_delete_documents_of_closed_query(self): self.assertEqual(response.status_code, status.HTTP_200_OK) response = response.json() self.assertIsNotNone(response["document"]["id"]) + + +class ECJUQueriesChaserNotificationTests(DataTestClient): + @freeze_time("2024-02-06 12:00:00") + def setUp(self): + super().setUp() + settings.GOV_NOTIFY_ENABLED = True + self.case = self.create_standard_application_case(self.organisation) + self.date_15_working_days_from_today = datetime.datetime(2024, 1, 16, 12, 00) + + self.ecju_query_case_1 = EcjuQueryFactory( + question="ECJU Query 15 days", + case=self.case, + raised_by_user=self.gov_user, + response=None, + created_at=self.date_15_working_days_from_today, + chaser_email_sent_on=self.date_15_working_days_from_today, + ) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.core.celery_tasks.NotificationsAPIClient.send_email_notification") + def test_schedule_all_ecju_query_chaser_emails_filters(self, mock_gov_notification): + + EcjuQueryFactory( + question="ECJU Query 14 days", + case=self.case, + raised_by_user=self.gov_user, + created_at=datetime.datetime(2024, 1, 17, 12, 00), + ) + + EcjuQueryFactory( + question="ECJU Query 15 days", + case=self.case, + raised_by_user=self.gov_user, + created_at=self.date_15_working_days_from_today, + ) + + EcjuQueryFactory( + question="ECJU Query reminder after 20 days", + case=self.case, + raised_by_user=self.gov_user, + created_at=datetime.datetime(2024, 1, 9, 12, 00), + ) + + EcjuQueryFactory( + question="ECJU Query reminder sent old", + case=self.case, + raised_by_user=self.gov_user, + created_at=datetime.datetime(2024, 1, 17, 12, 00), + ) + + self.assertEqual(EcjuQuery.objects.filter(chaser_email_sent_on__isnull=True).count(), 4) + + schedule_all_ecju_query_chaser_emails.apply() + self.assertEqual(mock_gov_notification.call_count, 2) + # Have extra because of the muliple ECJU Queries 15 day reminder on the same case + self.assertEqual(EcjuQuery.objects.filter(chaser_email_sent_on__isnull=True).count(), 2) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.core.celery_tasks.NotificationsAPIClient.send_email_notification") + def test_schedule_all_ecju_query_chaser_emails_filters_terminal(self, mock_gov_notification): + + self.case.status = get_case_status_by_status(CaseStatusEnum.FINALISED) + self.case.save() + + ecju_response_query = EcjuQueryFactory( + question="Terminal Case 15 days", + case=self.case, + raised_by_user=self.gov_user, + created_at=self.date_15_working_days_from_today, + ) + + schedule_all_ecju_query_chaser_emails.apply() + mock_gov_notification.assert_not_called() + + ecju_response_query.refresh_from_db() + self.assertIsNone(ecju_response_query.chaser_email_sent_on) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.core.celery_tasks.NotificationsAPIClient.send_email_notification") + def test_schedule_all_ecju_query_chaser_emails_filters_responded(self, mock_gov_notification): + + case_2 = self.create_standard_application_case(self.organisation) + + ecju_response_query = EcjuQueryFactory( + question="ECJU Query 2 15 days", + case=case_2, + response="I have a response", + raised_by_user=self.gov_user, + responded_by_user=self.base_user, + created_at=self.date_15_working_days_from_today, + ) + + schedule_all_ecju_query_chaser_emails.apply() + mock_gov_notification.assert_not_called() + + ecju_response_query.refresh_from_db() + self.assertIsNone(ecju_response_query.chaser_email_sent_on) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.core.celery_tasks.NotificationsAPIClient.send_email_notification") + def test_schedule_all_ecju_query_chaser_emails_filters_is_chaser_email_sent(self, mock_gov_notification): + + case_2 = self.create_standard_application_case(self.organisation) + + EcjuQueryFactory( + question="ECJU Query 15 days", + case=case_2, + raised_by_user=self.gov_user, + created_at=self.date_15_working_days_from_today, + chaser_email_sent_on=datetime.datetime.now(), + ) + + schedule_all_ecju_query_chaser_emails.apply() + mock_gov_notification.assert_not_called() + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.cases.notify.send_email") + def test_schedule_all_ecju_query_chaser_emails_send_mail_params(self, mock_send_email): + + self.ecju_query_case_1.chaser_email_sent_on = None + self.ecju_query_case_1.save() + + schedule_all_ecju_query_chaser_emails.apply() + + mock_send_email.assert_called_once() + expected_payload = ExporterECJUQueryChaser( + case_reference=self.case.reference_code, + exporter_frontend_ecju_queries_url=f"https://exporter.lite.service.localhost.uktrade.digital/applications/{self.case.pk}/ecju-queries/", + remaining_days=5, + ) + mock_send_email.assert_called_with( + self.case.submitted_by.email, + TemplateType.EXPORTER_ECJU_QUERY_CHASER, + expected_payload, + mark_ecju_queries_as_sent.si(self.ecju_query_case_1.pk), + ) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.core.celery_tasks.NotificationsAPIClient.send_email_notification") + def test_schedule_all_ecju_query_chaser_emails_callback_marks_sent(self, mock_gov_notification): + + self.ecju_query_case_1.chaser_email_sent_on = None + self.ecju_query_case_1.save() + + schedule_all_ecju_query_chaser_emails.apply() + + mock_gov_notification.assert_called_once() + + self.ecju_query_case_1.refresh_from_db() + + self.assertIsNotNone(self.ecju_query_case_1.chaser_email_sent_on) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.cases.celery_tasks.send_ecju_query_chaser_email.delay") + def test_send_ecju_query_notifications_raises_exception(self, mock_send_ecju_query_chaser_email): + self.ecju_query_case_1.chaser_email_sent_on = None + self.ecju_query_case_1.save() + + mock_send_ecju_query_chaser_email.side_effect = Exception() + with pytest.raises(Exception): + schedule_all_ecju_query_chaser_emails() + + mock_send_ecju_query_chaser_email.assert_called_once() + self.assertEqual(EcjuQuery.objects.filter(chaser_email_sent_on__isnull=False).count(), 0) + + @freeze_time("2024-02-06 12:00:00") + @mock.patch("api.cases.notify.send_email") + @mock.patch("api.cases.notify._notify_exporter_ecju_query_chaser") + def test_send_ecju_query_chaser_email_raises_exception(self, mock_notify_chaser_email, mock_send_email): + self.ecju_query_case_1.chaser_email_sent_on = None + self.ecju_query_case_1.save() + + mock_notify_chaser_email.side_effect = Exception() + + with pytest.raises(Exception): + send_ecju_query_chaser_email(self.ecju_query_case_1.pk) + mock_send_email.assert_not_called() + self.ecju_query_case_1.refresh_from_db() + self.assertIsNone(self.ecju_query_case_1.chaser_email_sent_on) + + @freeze_time("2024-02-06 12:00:00") + def test_mark_ecju_queries_as_sent(self): + self.ecju_query_case_1.chaser_email_sent_on = None + self.ecju_query_case_1.save_base() + + self.assertIsNone(self.ecju_query_case_1.responded_at) + + mark_ecju_queries_as_sent(self.ecju_query_case_1.pk) + + self.ecju_query_case_1.refresh_from_db() + self.assertIsNotNone(self.ecju_query_case_1.chaser_email_sent_on) + # Need to esnure responded_at is not impacted auto_now_add and save business logic shouldn't be executed + self.assertIsNone(self.ecju_query_case_1.responded_at) diff --git a/api/conf/celery.py b/api/conf/celery.py index d73c1f9232..286d5769d1 100644 --- a/api/conf/celery.py +++ b/api/conf/celery.py @@ -27,4 +27,8 @@ "task": "api.cases.celery_tasks.update_cases_sla", "schedule": crontab(hour=22, minute=30), }, + "send ecju query chaser emails 8pm, 4pm": { + "task": "api.cases.celery_tasks.schedule_all_ecju_query_chaser_emails", + "schedule": crontab(hour="8, 16", minute=0), + }, } diff --git a/api/data_workspace/tests/test_case_views.py b/api/data_workspace/tests/test_case_views.py index 8ce50a071f..49fbf69540 100644 --- a/api/data_workspace/tests/test_case_views.py +++ b/api/data_workspace/tests/test_case_views.py @@ -94,6 +94,7 @@ def test_ecju_queries(self): "responded_by_user", "responded_at", "created_at", + "chaser_email_sent_on", } allowed_actions = {"HEAD", "OPTIONS", "GET"} diff --git a/gov_notify/enums.py b/gov_notify/enums.py index 9037f15f2a..3dab7e8798 100644 --- a/gov_notify/enums.py +++ b/gov_notify/enums.py @@ -12,6 +12,7 @@ class TemplateType(Enum): EXPORTER_ORGANISATION_REJECTED = "exporter_organisation_rejected" EXPORTER_CASE_OPENED_FOR_EDITING = "exporter_editing" EXPORTER_ECJU_QUERY = "exporter_ecju_query" + EXPORTER_ECJU_QUERY_CHASER = "exporter_ecju_query_chaser" EXPORTER_NO_LICENCE_REQUIRED = "exporter_no_licence_required" EXPORTER_INFORM_LETTER = "exporter_inform_letter" EXPORTER_APPEAL_ACKNOWLEDGEMENT = "exporter_appeal_acknowledgement" @@ -33,6 +34,7 @@ def template_id(self): self.EXPORTER_ORGANISATION_APPROVED: "d5e94717-ae78-4d18-8064-ecfcd99143f1", self.EXPORTER_ORGANISATION_REJECTED: "1dec3acd-94b0-47bb-832a-384ba5c6f51a", self.EXPORTER_ECJU_QUERY: "84431173-72a9-43a1-8926-b43dec7871f9", + self.EXPORTER_ECJU_QUERY_CHASER: "3ba8579c-ba2a-40bc-a302-9429cc465c96", self.EXPORTER_CASE_OPENED_FOR_EDITING: "73121bc2-2f03-4c66-8e88-61a156c05559", self.EXPORTER_NO_LICENCE_REQUIRED: "d84d1843-882c-440e-9cd4-84972ba612e6", self.EXPORTER_INFORM_LETTER: "7b63296f-af08-46bf-961e-19bdde93761c", diff --git a/gov_notify/payloads.py b/gov_notify/payloads.py index 73db8871ed..4fc003c9b5 100644 --- a/gov_notify/payloads.py +++ b/gov_notify/payloads.py @@ -77,6 +77,13 @@ class ExporterECJUQuery(EmailData): exporter_frontend_url: str +@dataclass(frozen=True) +class ExporterECJUQueryChaser(EmailData): + case_reference: str + exporter_frontend_ecju_queries_url: str + remaining_days: int + + @dataclass(frozen=True) class CaseWorkerNewRegistration(EmailData): organisation_name: str diff --git a/gov_notify/service.py b/gov_notify/service.py index f6cebd328f..873cbd03fe 100644 --- a/gov_notify/service.py +++ b/gov_notify/service.py @@ -10,14 +10,13 @@ logger = logging.getLogger(__name__) -def send_email(email_address, template_type, data: Optional[EmailData] = None): +def send_email(email_address, template_type, data: Optional[EmailData] = None, callback=None): """ Send an email using the gov notify service via celery. """ if not settings.GOV_NOTIFY_ENABLED: logging.info({"gov_notify": "disabled"}) return - data = data.as_dict() if data else None logger.info("sending email via celery") - return celery_send_email.apply_async([email_address, template_type.template_id, data]) + return celery_send_email.apply_async([email_address, template_type.template_id, data], link=callback)