Skip to content

Commit d31cd06

Browse files
authored
EAP: email notification setup (#2624)
1 parent 307200f commit d31cd06

23 files changed

+1754
-14
lines changed

assets

deploy/helm/ifrcgo-helm/values.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ cronjobs:
279279
# https://github.com/jazzband/django-oauth-toolkit/blob/master/docs/management_commands.rst#cleartokens
280280
- command: 'oauth_cleartokens'
281281
schedule: '0 1 * * *'
282+
- command: 'eap_submission_reminder'
283+
schedule: '0 0 * * *'
282284

283285

284286
elasticsearch:

eap/dev_views.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from django.http import HttpResponse
2+
from django.template import loader
3+
from rest_framework import permissions
4+
from rest_framework.views import APIView
5+
6+
7+
class EAPEmailPreview(APIView):
8+
permission_classes = [permissions.IsAuthenticated]
9+
10+
def get(self, request):
11+
type_param = request.GET.get("type")
12+
13+
template_map = {
14+
"registration": "email/eap/registration.html",
15+
"submission": "email/eap/submission.html",
16+
"feedback_to_national_society": "email/eap/feedback_to_national_society.html",
17+
"resubmission_of_revised_eap": "email/eap/re-submission.html",
18+
"feedback_for_revised_eap": "email/eap/feedback_to_revised_eap.html",
19+
"technically_validated_eap": "email/eap/technically_validated_eap.html",
20+
"pending_pfa": "email/eap/pending_pfa.html",
21+
"approved_eap": "email/eap/approved.html",
22+
"reminder": "email/eap/reminder.html",
23+
}
24+
25+
if type_param not in template_map:
26+
valid_values = ", ".join(template_map.keys())
27+
return HttpResponse(
28+
f"Invalid 'type' parameter. Please use one of the following values: {valid_values}.",
29+
)
30+
31+
context_map = {
32+
"registration": {
33+
"eap_type_display": "FULL EAP",
34+
"country_name": "Test Country",
35+
"national_society": "Test National Society",
36+
"supporting_partners": [
37+
{"society_name": "Partner 1"},
38+
{"society_name": "Partner 2"},
39+
],
40+
"disaster_type": "Flood",
41+
"ns_contact_name": "Test registration name",
42+
"ns_contact_email": "[email protected]",
43+
"ns_contact_phone": "1234567890",
44+
},
45+
"submission": {
46+
"eap_type_display": "SIMPLIFIED EAP",
47+
"country_name": "Test Country",
48+
"national_society": "Test National Society",
49+
"people_targated": 100,
50+
"latest_eap_id": 1,
51+
"supporting_partners": [
52+
{"society_name": "Partner NS 1"},
53+
{"society_name": "Partner NS 2"},
54+
],
55+
"disaster_type": "Flood",
56+
"total_budget": "250,000 CHF",
57+
"ns_contact_name": "Test Ns Contact name",
58+
"ns_contact_email": "[email protected]",
59+
"ns_contact_phone": "+977-9800000000",
60+
},
61+
"feedback_to_national_society": {
62+
"registration_id": 1,
63+
"eap_type_display": "FULL EAP",
64+
"country_name": "Test Country",
65+
"national_society": "Test National Society",
66+
},
67+
"resubmission_of_revised_eap": {
68+
"latest_eap_id": 1,
69+
"eap_type_display": "SIMPLIFIED EAP",
70+
"country_name": "Test Country",
71+
"national_society": "Test National Society",
72+
"supporting_partners": [
73+
{"society_name": "Partner NS 1"},
74+
{"society_name": "Partner NS 2"},
75+
],
76+
"version": 2 or 3,
77+
"people_targated": 100,
78+
"disaster_type": "Flood",
79+
"total_budget": "250,000 CHF",
80+
"ns_contact_name": "Test Ns Contact name",
81+
"ns_contact_email": "[email protected]",
82+
"ns_contact_phone": "+977-9800000000",
83+
},
84+
"feedback_for_revised_eap": {
85+
"registration_id": 1,
86+
"eap_type_display": "FULL EAP",
87+
"country_name": "Test Country",
88+
"national_society": "Test National Society",
89+
"version": 2,
90+
},
91+
"technically_validated_eap": {
92+
"registration_id": 1,
93+
"eap_type_display": "FULL EAP",
94+
"country_name": "Test Country",
95+
"national_society": "Test National Society",
96+
"disaster_type": "Flood",
97+
},
98+
"pending_pfa": {
99+
"eap_type_display": "FULL EAP",
100+
"country_name": "Test Country",
101+
"national_society": "Test National Society",
102+
"disaster_type": "Flood",
103+
},
104+
"approved_eap": {
105+
"eap_type_display": "FULL EAP",
106+
"country_name": "Test Country",
107+
"national_society": "Test National Society",
108+
"disaster_type": "Flood",
109+
},
110+
"reminder": {
111+
"eap_type_display": "FULL EAP",
112+
"country_name": "Test Country",
113+
"national_society": "Test National Society",
114+
"disaster_type": "Flood",
115+
},
116+
}
117+
118+
context = context_map.get(type_param)
119+
if context is None:
120+
return HttpResponse("No context found for the email preview.")
121+
template_file = template_map[type_param]
122+
template = loader.get_template(template_file)
123+
return HttpResponse(template.render(context, request))
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from datetime import timedelta
2+
3+
from django.core.management.base import BaseCommand
4+
from django.utils import timezone
5+
from sentry_sdk.crons import monitor
6+
7+
from eap.models import EAPRegistration
8+
from eap.tasks import send_deadline_reminder_email
9+
from main.sentry import SentryMonitor
10+
11+
12+
class Command(BaseCommand):
13+
help = "Send EAP submission reminder emails 1 week before deadline"
14+
15+
@monitor(monitor_slug=SentryMonitor.EAP_SUBMISSION_REMINDER)
16+
def handle(self, *args, **options):
17+
"""
18+
Finds EAP-registrations whose submission deadline is exactly 1 week from today
19+
and sends reminder emails for each matching registration.
20+
"""
21+
target_date = timezone.now().date() + timedelta(weeks=1)
22+
queryset = EAPRegistration.objects.filter(
23+
deadline=target_date,
24+
)
25+
26+
if not queryset.exists():
27+
self.stdout.write(self.style.NOTICE("No EAP registrations found for deadline reminder."))
28+
return
29+
30+
for instance in queryset.iterator():
31+
self.stdout.write(self.style.NOTICE(f"Sending deadline reminder email for EAPRegistration ID={instance.id}"))
32+
send_deadline_reminder_email(instance.id)
33+
34+
self.stdout.write(self.style.SUCCESS("Successfully sent all deadline reminder emails."))
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 4.2.26 on 2026-01-08 07:14
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('eap', '0014_eapcontact_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='eapregistration',
15+
name='deadline',
16+
field=models.DateField(blank=True, help_text='Date by which the EAP submission must be completed.', null=True, verbose_name='deadline'),
17+
),
18+
migrations.AddField(
19+
model_name='eapregistration',
20+
name='deadline_remainder_sent_at',
21+
field=models.DateTimeField(blank=True, help_text='Timestamp when the deadline reminder email was sent.', null=True, verbose_name='deadline reminder email sent at'),
22+
),
23+
]

eap/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,21 @@ class EAPRegistration(EAPBaseModel):
722722
help_text=_("Timestamp when the EAP was activated."),
723723
)
724724

725+
# EAP submission deadline
726+
deadline = models.DateField(
727+
null=True,
728+
blank=True,
729+
verbose_name=_("deadline"),
730+
help_text=_("Date by which the EAP submission must be completed."),
731+
)
732+
733+
deadline_remainder_sent_at = models.DateTimeField(
734+
null=True,
735+
blank=True,
736+
verbose_name=_("deadline reminder email sent at"),
737+
help_text=_("Timestamp when the deadline reminder email was sent."),
738+
)
739+
725740
# TYPING
726741
id: int
727742
national_society_id: int

eap/serializers.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing
2+
from datetime import timedelta
23

34
from celery import group
45
from django.contrib.auth.models import User
@@ -39,6 +40,14 @@
3940
generate_eap_summary_pdf,
4041
generate_export_diff_pdf,
4142
generate_export_eap_pdf,
43+
send_approved_email,
44+
send_eap_resubmission_email,
45+
send_feedback_email,
46+
send_feedback_email_for_resubmitted_eap,
47+
send_new_eap_registration_email,
48+
send_new_eap_submission_email,
49+
send_pending_pfa_email,
50+
send_technical_validation_email,
4251
)
4352
from eap.utils import (
4453
has_country_permission,
@@ -189,8 +198,19 @@ class Meta:
189198
"modified_by",
190199
"latest_simplified_eap",
191200
"latest_full_eap",
201+
"deadline",
192202
]
193203

204+
def create(self, validated_data: dict[str, typing.Any]):
205+
instance = super().create(validated_data)
206+
207+
transaction.on_commit(
208+
lambda: send_new_eap_registration_email.delay(
209+
instance.id,
210+
)
211+
)
212+
return instance
213+
194214
def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]:
195215
# NOTE: Cannot update once EAP application is being created.
196216
if instance.has_eap_application:
@@ -962,3 +982,109 @@ def validate_review_checklist_file(self, file):
962982
validate_file_type(file)
963983

964984
return file
985+
986+
def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> EAPRegistration:
987+
old_status = instance.get_status_enum
988+
updated_instance = super().update(instance, validated_data)
989+
new_status = updated_instance.get_status_enum
990+
991+
if old_status == new_status:
992+
return updated_instance
993+
994+
eap_registration_id = updated_instance.id
995+
assert updated_instance.get_eap_type_enum is not None, "EAP type must not be None"
996+
997+
if updated_instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
998+
eap_count = SimplifiedEAP.objects.filter(eap_registration=updated_instance).count()
999+
else:
1000+
eap_count = FullEAP.objects.filter(eap_registration=updated_instance).count()
1001+
1002+
if (old_status, new_status) == (
1003+
EAPRegistration.Status.UNDER_DEVELOPMENT,
1004+
EAPRegistration.Status.UNDER_REVIEW,
1005+
):
1006+
transaction.on_commit(lambda: send_new_eap_submission_email.delay(eap_registration_id))
1007+
1008+
elif (old_status, new_status) == (
1009+
EAPRegistration.Status.UNDER_REVIEW,
1010+
EAPRegistration.Status.NS_ADDRESSING_COMMENTS,
1011+
):
1012+
"""
1013+
NOTE:
1014+
At the transition (UNDER_REVIEW -> NS_ADDRESSING_COMMENTS), the EAP snapshot
1015+
is generated inside `_validate_status()` BEFORE we reach this `update()` method.
1016+
1017+
That snapshot operation:
1018+
- Locks the reviewed EAP (previous version)
1019+
- Creates a new snapshot (incremented version)
1020+
- Updates latest_simplified_eap or latest_full_eap to the new version
1021+
1022+
Email logic based on eap_count:
1023+
- If eap_count == 2 (i.e., first snapshot already exists and this is the first IFRC feedback cycle)
1024+
- Send the first feedback email
1025+
- Else (eap_count > 2), indicating subsequent feedback cycles:
1026+
- Send the resubmitted feedback email
1027+
1028+
Therefore:
1029+
- version == 2 always corresponds to the first IFRC feedback cycle
1030+
- Any later versions (>= 3) correspond to resubmitted cycles
1031+
1032+
Deadline update rules:
1033+
- First IFRC feedback cycle: deadline is set to 90 days from the current date.
1034+
- Subsequent feedback or resubmission cycles: deadline is set to 30 days from the current date.
1035+
"""
1036+
1037+
if eap_count == 2:
1038+
updated_instance.deadline = timezone.now().date() + timedelta(days=90)
1039+
updated_instance.save(
1040+
update_fields=[
1041+
"deadline",
1042+
]
1043+
)
1044+
transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id))
1045+
1046+
elif eap_count > 2:
1047+
updated_instance.deadline = timezone.now().date() + timedelta(days=30)
1048+
updated_instance.save(
1049+
update_fields=[
1050+
"deadline",
1051+
]
1052+
)
1053+
transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id))
1054+
1055+
elif (old_status, new_status) == (
1056+
EAPRegistration.Status.NS_ADDRESSING_COMMENTS,
1057+
EAPRegistration.Status.UNDER_REVIEW,
1058+
):
1059+
transaction.on_commit(lambda: send_eap_resubmission_email.delay(eap_registration_id))
1060+
elif (old_status, new_status) == (
1061+
EAPRegistration.Status.TECHNICALLY_VALIDATED,
1062+
EAPRegistration.Status.NS_ADDRESSING_COMMENTS,
1063+
):
1064+
updated_instance.deadline = timezone.now().date() + timedelta(days=30)
1065+
updated_instance.save(
1066+
update_fields=[
1067+
"deadline",
1068+
]
1069+
)
1070+
transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id))
1071+
1072+
elif (old_status, new_status) == (
1073+
EAPRegistration.Status.UNDER_REVIEW,
1074+
EAPRegistration.Status.TECHNICALLY_VALIDATED,
1075+
):
1076+
transaction.on_commit(lambda: send_technical_validation_email.delay(eap_registration_id))
1077+
1078+
elif (old_status, new_status) == (
1079+
EAPRegistration.Status.TECHNICALLY_VALIDATED,
1080+
EAPRegistration.Status.PENDING_PFA,
1081+
):
1082+
transaction.on_commit(lambda: send_pending_pfa_email.delay(eap_registration_id))
1083+
1084+
elif (old_status, new_status) == (
1085+
EAPRegistration.Status.PENDING_PFA,
1086+
EAPRegistration.Status.APPROVED,
1087+
):
1088+
transaction.on_commit(lambda: send_approved_email.delay(eap_registration_id))
1089+
1090+
return updated_instance

0 commit comments

Comments
 (0)