From 5d22d32d32f099c87f41e05966f086b138abc478 Mon Sep 17 00:00:00 2001
From: sudip-khanal
Date: Thu, 18 Dec 2025 17:38:37 +0545
Subject: [PATCH 1/2] feat(eap): Add EAP email workflow for different status
transition
---
eap/serializers.py | 92 ++++-
eap/tasks.py | 388 +++++++++++++++++-
eap/test_views.py | 307 ++++++++++++++
eap/utils.py | 31 +-
.../eap/feedback_to_national_society.html | 6 +-
.../email/eap/feedback_to_revised_eap.html | 8 +-
.../templates/email/eap/pending_pfa.html | 15 +-
.../templates/email/eap/re-submission.html | 15 +-
.../templates/email/eap/submission.html | 10 +-
.../email/eap/technically_validated_eap.html | 4 +-
10 files changed, 847 insertions(+), 29 deletions(-)
diff --git a/eap/serializers.py b/eap/serializers.py
index ae269cd5f..9e3059909 100644
--- a/eap/serializers.py
+++ b/eap/serializers.py
@@ -33,7 +33,16 @@
TimeFrame,
YearsTimeFrameChoices,
)
-from eap.tasks import send_new_eap_registration_email
+from eap.tasks import (
+ send_approved_email,
+ send_eap_resubmission_email,
+ send_feedback_email,
+ send_feedback_email_for_resubmitted_eap,
+ send_new_eap_registration_email,
+ send_new_eap_submission_email,
+ send_pending_pfa_email,
+ send_technical_validation_email,
+)
from eap.utils import (
has_country_permission,
is_user_ifrc_admin,
@@ -906,3 +915,84 @@ def validate_review_checklist_file(self, file):
validate_file_type(file)
return file
+
+ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> EAPRegistration:
+ old_status = instance.get_status_enum
+ updated_instance = super().update(instance, validated_data)
+ new_status = updated_instance.get_status_enum
+
+ if old_status == new_status:
+ return updated_instance
+
+ eap_registration_id = updated_instance.id
+ if updated_instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ eap_count = SimplifiedEAP.objects.filter(eap_registration=updated_instance).count()
+ else:
+ eap_count = FullEAP.objects.filter(eap_registration=updated_instance).count()
+
+ if (old_status, new_status) == (
+ EAPRegistration.Status.UNDER_DEVELOPMENT,
+ EAPRegistration.Status.UNDER_REVIEW,
+ ):
+ transaction.on_commit(lambda: send_new_eap_submission_email.delay(eap_registration_id))
+
+ elif (old_status, new_status) == (
+ EAPRegistration.Status.UNDER_REVIEW,
+ EAPRegistration.Status.NS_ADDRESSING_COMMENTS,
+ ):
+ """
+ NOTE:
+ At the transition (UNDER_REVIEW -> NS_ADDRESSING_COMMENTS), the EAP snapshot
+ is generated inside `_validate_status()` BEFORE we reach this `update()` method.
+
+ That snapshot operation:
+ - Locks the reviewed EAP (previous version)
+ - Creates a new snapshot (incremented version)
+ - Updates latest_simplified_eap or latest_full_eap to the new version
+
+ Email logic based on eap_count:
+ - If eap_count == 2 (i.e., first snapshot already exists and this is the first IFRC feedback cycle)
+ - Send the first feedback email
+ - Else (eap_count > 2), indicating subsequent feedback cycles:
+ - Send the resubmitted feedback email
+
+ Therefore:
+ - version == 2 always corresponds to the first IFRC feedback cycle
+ - Any later versions (>= 3) correspond to resubmitted cycles
+ """
+
+ if eap_count == 2:
+ transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id))
+ else:
+ transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id))
+
+ elif (old_status, new_status) == (
+ EAPRegistration.Status.NS_ADDRESSING_COMMENTS,
+ EAPRegistration.Status.UNDER_REVIEW,
+ ):
+ transaction.on_commit(lambda: send_eap_resubmission_email.delay(eap_registration_id))
+ elif (old_status, new_status) == (
+ EAPRegistration.Status.TECHNICALLY_VALIDATED,
+ EAPRegistration.Status.NS_ADDRESSING_COMMENTS,
+ ):
+ transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id))
+
+ elif (old_status, new_status) == (
+ EAPRegistration.Status.UNDER_REVIEW,
+ EAPRegistration.Status.TECHNICALLY_VALIDATED,
+ ):
+ transaction.on_commit(lambda: send_technical_validation_email.delay(eap_registration_id))
+
+ elif (old_status, new_status) == (
+ EAPRegistration.Status.TECHNICALLY_VALIDATED,
+ EAPRegistration.Status.PENDING_PFA,
+ ):
+ transaction.on_commit(lambda: send_pending_pfa_email.delay(eap_registration_id))
+
+ elif (old_status, new_status) == (
+ EAPRegistration.Status.PENDING_PFA,
+ EAPRegistration.Status.APPROVED,
+ ):
+ transaction.on_commit(lambda: send_approved_email.delay(eap_registration_id))
+
+ return updated_instance
diff --git a/eap/tasks.py b/eap/tasks.py
index 616819951..a4999f69e 100644
--- a/eap/tasks.py
+++ b/eap/tasks.py
@@ -5,11 +5,8 @@
from django.contrib.auth import get_user_model
from django.template.loader import render_to_string
-from eap.models import EAPRegistration
-from eap.utils import (
- get_coordinator_emails_by_region,
- get_eap_registration_email_context,
-)
+from eap.models import EAPRegistration, EAPType, FullEAP, SimplifiedEAP
+from eap.utils import get_coordinator_emails_by_region, get_eap_email_context
from notifications.notification import send_notification
User = get_user_model()
@@ -39,7 +36,7 @@ def send_new_eap_registration_email(eap_registration_id: int):
]
)
)
- email_context = get_eap_registration_email_context(instance)
+ email_context = get_eap_email_context(instance)
email_subject = (
f"[{instance.get_eap_type_display() if instance.get_eap_type_display() else 'EAP'} IN DEVELOPMENT] "
f"{instance.country} {instance.disaster_type}"
@@ -56,3 +53,382 @@ def send_new_eap_registration_email(eap_registration_id: int):
)
return email_context
+
+
+@shared_task
+def send_new_eap_submission_email(eap_registration_id: int):
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_email
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipients = [
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ instance.ifrc_contact_email,
+ ]
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ instance.national_society_contact_email,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = (
+ f"[DREF {instance.get_eap_type_display()} FOR REVIEW] " f"{instance.country} {instance.disaster_type} TO THE IFRC-DREF"
+ )
+ email_body = render_to_string("email/eap/submission.html", email_context)
+ email_type = "EAP Submission"
+ send_notification(
+ subject=email_subject,
+ recipients=recipients,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+
+ return email_context
+
+
+@shared_task
+def send_feedback_email(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ ifrc_delegation_focal_point_email = latest_simplified_eap.ifrc_delegation_focal_point_email
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_name
+ ifrc_delegation_focal_point_email = latest_full_eap.ifrc_delegation_focal_point_email
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipient = [
+ instance.national_society_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ ifrc_delegation_focal_point_email,
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = (
+ f"[DREF {instance.get_eap_type_display()} FEEDBACK] "
+ f"{instance.country} {instance.disaster_type} TO THE {instance.national_society}"
+ )
+ email_body = render_to_string("email/eap/feedback_to_national_society.html", email_context)
+ email_type = "Feedback to the National Society"
+ send_notification(
+ subject=email_subject,
+ recipients=recipient,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+
+ return email_context
+
+
+@shared_task
+def send_eap_resubmission_email(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ latest_version = latest_simplified_eap.version
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_name
+ latest_version = latest_full_eap.version
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipients = [
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ instance.ifrc_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ instance.national_society_contact_email,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = (
+ f"[DREF {instance.get_eap_type_display()} FOR REVIEW] "
+ f"{instance.country} {instance.disaster_type} version {latest_version} TO THE IFRC-DREF"
+ )
+ email_body = render_to_string("email/eap/re-submission.html", email_context)
+ email_type = "Feedback to the National Society"
+ send_notification(
+ subject=email_subject,
+ recipients=recipients,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+
+ return email_context
+
+
+@shared_task
+def send_feedback_email_for_resubmitted_eap(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ partner_ns_email = instance.latest_simplified_eap.partner_ns_email
+ latest_version = instance.latest_simplified_eap.version
+ qs = SimplifiedEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first()
+ previous_version = qs.version if qs else None
+
+ else:
+ partner_ns_email = instance.latest_full_eap.partner_ns_email
+ latest_version = instance.latest_full_eap.version
+ qs = FullEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first()
+ previous_version = qs.version if qs else None
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipients = [
+ instance.national_society_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = (
+ f"[DREF {instance.get_eap_type_display()} FEEDBACK] "
+ f"{instance.country} {instance.disaster_type} version {previous_version} TO {instance.national_society}"
+ )
+ email_body = render_to_string("email/eap/feedback_to_revised_eap.html", email_context)
+ email_type = "Feedback to the National Society"
+ send_notification(
+ subject=email_subject,
+ recipients=recipients,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+
+ return email_context
+
+
+@shared_task
+def send_technical_validation_email(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_name
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipient = [
+ instance.national_society_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = f"[DREF {instance.get_eap_type_display()} TECHNICALLY VALIDATED] {instance.country} {instance.disaster_type}"
+ email_body = render_to_string("email/eap/technically_validated_eap.html", email_context)
+ email_type = "Technically Validated EAP"
+ send_notification(
+ subject=email_subject,
+ recipients=recipient,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+ return email_context
+
+
+@shared_task
+def send_pending_pfa_email(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_name
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipient = [
+ instance.national_society_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED PENDING PFA] {instance.country} {instance.disaster_type}"
+ email_body = render_to_string("email/eap/pending_pfa.html", email_context)
+ email_type = "Approved Pending PFA EAP"
+ send_notification(
+ subject=email_subject,
+ recipients=recipient,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+ return email_context
+
+
+@shared_task
+def send_approved_email(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ email_context = "Simplified EAP"
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_name
+ email_context = "Full EAP"
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipient = [
+ instance.national_society_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED] {instance.country} {instance.disaster_type}"
+ email_body = render_to_string("email/eap/approved.html", email_context)
+ email_type = "Approved EAP"
+ send_notification(
+ subject=email_subject,
+ recipients=recipient,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+ return email_context
+
+
+@shared_task
+def send_deadline_reminder_email(eap_registration_id: int):
+
+ instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
+ if not instance:
+ return None
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_simplified_eap = instance.latest_simplified_eap
+ partner_ns_email = latest_simplified_eap.partner_ns_email
+ else:
+ latest_full_eap = instance.latest_full_eap
+ partner_ns_email = latest_full_eap.partner_ns_name
+
+ regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region)
+
+ recipient = [
+ instance.national_society_contact_email,
+ ]
+
+ cc_recipients = list(
+ set(
+ [
+ partner_ns_email,
+ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR,
+ *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM,
+ *regional_coordinator_emails,
+ ]
+ )
+ )
+ email_context = get_eap_email_context(instance)
+ email_subject = f"[DREF {instance.get_eap_type_display()} SUBMISSION REMINDER] {instance.country} {instance.disaster_type}"
+ email_body = render_to_string("email/eap/approved.html", email_context)
+ email_type = "Approved EAP"
+ send_notification(
+ subject=email_subject,
+ recipients=recipient,
+ html=email_body,
+ mailtype=email_type,
+ cc_recipients=cc_recipients,
+ )
+ return email_context
diff --git a/eap/test_views.py b/eap/test_views.py
index 50039c325..4d564d796 100644
--- a/eap/test_views.py
+++ b/eap/test_views.py
@@ -1600,6 +1600,313 @@ def test_status_transition(self):
self.eap_registration.refresh_from_db()
self.assertIsNotNone(self.eap_registration.activated_at)
+ @mock.patch("eap.serializers.send_new_eap_submission_email")
+ @mock.patch("eap.serializers.send_feedback_email")
+ @mock.patch("eap.serializers.send_eap_resubmission_email")
+ @mock.patch("eap.serializers.send_technical_validation_email")
+ @mock.patch("eap.serializers.send_feedback_email_for_resubmitted_eap")
+ @mock.patch("eap.serializers.send_pending_pfa_email")
+ @mock.patch("eap.serializers.send_approved_email")
+ def test_status_transitions_trigger_email(
+ self,
+ send_approved_email,
+ send_pending_pfa_email,
+ send_feedback_email_for_resubmitted_eap,
+ send_technical_validation_email,
+ send_eap_resubmission_email,
+ send_feedback_email,
+ send_new_eap_submission_email,
+ ):
+
+ # Create permissions
+ management.call_command("make_permissions")
+
+ self.country_admin = UserFactory.create()
+ country_admin_permission = Permission.objects.filter(codename="country_admin_%s" % self.national_society.id).first()
+ country_group = Group.objects.filter(name="%s Admins" % self.national_society.name).first()
+
+ self.country_admin.user_permissions.add(country_admin_permission)
+ self.country_admin.groups.add(country_group)
+
+ # Create IFRC Admin User and assign permission
+ self.ifrc_admin_user = UserFactory.create()
+ ifrc_admin_permission = Permission.objects.filter(codename="ifrc_admin").first()
+ ifrc_group = Group.objects.filter(name="IFRC Admins").first()
+ self.ifrc_admin_user.user_permissions.add(ifrc_admin_permission)
+ self.ifrc_admin_user.groups.add(ifrc_group)
+
+ eap_registration = EAPRegistrationFactory.create(
+ country=self.country,
+ national_society=self.national_society,
+ disaster_type=self.disaster_type,
+ eap_type=EAPType.SIMPLIFIED_EAP,
+ status=EAPStatus.UNDER_DEVELOPMENT,
+ partners=[self.partner1.id, self.partner2.id],
+ created_by=self.user,
+ modified_by=self.user,
+ )
+ simplified_eap = SimplifiedEAPFactory.create(
+ eap_registration=eap_registration,
+ created_by=self.country_admin,
+ modified_by=self.country_admin,
+ budget_file=EAPFileFactory._create_file(
+ created_by=self.country_admin,
+ modified_by=self.country_admin,
+ ),
+ )
+ eap_registration.latest_simplified_eap = simplified_eap
+ eap_registration.save()
+
+ url = f"/api/v2/eap-registration/{eap_registration.id}/status/"
+
+ # UNDER_DEVELOPMENT -> UNDER_REVIEW
+ data = {"status": EAPStatus.UNDER_REVIEW}
+ self.authenticate(self.country_admin)
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW)
+ send_new_eap_submission_email.delay.assert_called_once_with(eap_registration.id)
+ send_new_eap_submission_email.delay.reset_mock()
+
+ # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS
+ data = {
+ "status": EAPStatus.NS_ADDRESSING_COMMENTS,
+ }
+ # Login as IFRC admin user
+ self.authenticate(self.ifrc_admin_user)
+
+ # Upload checklist and change status in a single request
+ with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file:
+ tmp_file.write(b"Test content")
+ tmp_file.seek(0)
+
+ data = {
+ "status": EAPStatus.NS_ADDRESSING_COMMENTS,
+ "review_checklist_file": tmp_file,
+ }
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="multipart")
+
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertIsNotNone(
+ self.eap_registration.review_checklist_file,
+ )
+ self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS)
+ send_feedback_email.delay.assert_called_once_with(eap_registration.id)
+ send_feedback_email.delay.reset_mock()
+ # -----------------------------
+ # Check snapshots after the status change
+ # -----------------------------
+ snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first()
+ assert snapshot is not None, "Snapshot should exist now"
+ eap_registration.latest_simplified_eap = snapshot
+ eap_registration.save()
+
+ # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW
+ # Upload updated checklist file
+ # UPDATES on the second snapshot
+ snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/"
+ checklist_file_instance = EAPFileFactory._create_file(
+ created_by=self.country_admin,
+ modified_by=self.country_admin,
+ )
+ file_data = {
+ "prioritized_hazard_and_impact": "Floods with potential heavy impact.",
+ "eap_registration": snapshot.eap_registration_id,
+ "updated_checklist_file": checklist_file_instance.id,
+ }
+ self.authenticate(self.country_admin)
+ response = self.client.patch(snapshot_url, file_data, format="json")
+ self.assert_200(response)
+
+ data = {"status": EAPStatus.UNDER_REVIEW}
+ self.authenticate(self.country_admin)
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW)
+ send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id)
+ send_eap_resubmission_email.delay.reset_mock()
+
+ # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS
+ # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS
+ # Login as IFRC admin user
+ self.authenticate(self.ifrc_admin_user)
+
+ # Upload checklist and change status in a single request
+ with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file:
+ tmp_file.write(b"Test content")
+ tmp_file.seek(0)
+
+ data = {
+ "status": EAPStatus.NS_ADDRESSING_COMMENTS,
+ "review_checklist_file": tmp_file,
+ }
+
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="multipart")
+
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertIsNotNone(
+ self.eap_registration.review_checklist_file,
+ )
+ self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS)
+ send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id)
+ send_feedback_email_for_resubmitted_eap.delay.reset_mock()
+ # -----------------------------
+ # Check snapshots after the status change
+ # -----------------------------
+ snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first()
+ assert snapshot is not None, "Snapshot should exist now"
+ eap_registration.latest_simplified_eap = snapshot
+ eap_registration.save()
+
+ # NOTE: Again Transition to UNDER_REVIEW
+ # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW
+ snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/"
+ checklist_file_instance = EAPFileFactory._create_file(
+ created_by=self.country_admin,
+ modified_by=self.country_admin,
+ )
+ file_data = {
+ "prioritized_hazard_and_impact": "Floods with potential heavy impact.",
+ "eap_registration": snapshot.eap_registration_id,
+ "updated_checklist_file": checklist_file_instance.id,
+ }
+ self.authenticate(self.country_admin)
+ response = self.client.patch(snapshot_url, file_data, format="json")
+ self.assert_200(response)
+
+ data = {"status": EAPStatus.UNDER_REVIEW}
+ self.authenticate(self.country_admin)
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW)
+ send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id)
+ send_eap_resubmission_email.delay.reset_mock()
+
+ # Transition UNDER_REVIEW -> TECHNICALLY_VALIDATED
+ data = {"status": EAPStatus.TECHNICALLY_VALIDATED}
+ self.authenticate(self.ifrc_admin_user)
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED)
+ eap_registration.refresh_from_db()
+ send_technical_validation_email.delay.assert_called_once_with(eap_registration.id)
+ send_technical_validation_email.delay.reset_mock()
+
+ # Transition TECHNICALLY_VALIDATED -> NS_ADDRESSING_COMMENTS
+ # Login as IFRC admin user
+ self.authenticate(self.ifrc_admin_user)
+
+ # Upload checklist and change status in a single request
+ with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file:
+ tmp_file.write(b"Test content")
+ tmp_file.seek(0)
+
+ data = {
+ "status": EAPStatus.NS_ADDRESSING_COMMENTS,
+ "review_checklist_file": tmp_file,
+ }
+
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="multipart")
+
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertIsNotNone(
+ self.eap_registration.review_checklist_file,
+ )
+ self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS)
+ send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id)
+ send_feedback_email_for_resubmitted_eap.delay.reset_mock()
+ # -----------------------------
+ # Check snapshots after the status change
+ # -----------------------------
+ snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first()
+ assert snapshot is not None, "Snapshot should exist now"
+ simplified_eap.refresh_from_db()
+ eap_registration.latest_simplified_eap = snapshot
+ eap_registration.save()
+
+ # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW
+ # Upload updated checklist file
+ # UPDATES on the second snapshot
+ snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/"
+ checklist_file_instance = EAPFileFactory._create_file(
+ created_by=self.country_admin,
+ modified_by=self.country_admin,
+ )
+ file_data = {
+ "prioritized_hazard_and_impact": "Floods with potential heavy impact.",
+ "eap_registration": snapshot.eap_registration_id,
+ "updated_checklist_file": checklist_file_instance.id,
+ }
+ self.authenticate(self.country_admin)
+ response = self.client.patch(snapshot_url, file_data, format="json")
+ self.assert_200(response)
+
+ data = {"status": EAPStatus.UNDER_REVIEW}
+ self.authenticate(self.country_admin)
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW)
+ send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id)
+ send_eap_resubmission_email.delay.reset_mock()
+
+ # Again Transition UNDER_REVIEW -> TECHNICALLY_VALIDATED
+ data = {"status": EAPStatus.TECHNICALLY_VALIDATED}
+ self.authenticate(self.ifrc_admin_user)
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ eap_registration.refresh_from_db()
+ self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED)
+ send_technical_validation_email.delay.assert_called_once_with(eap_registration.id)
+ send_technical_validation_email.delay.reset_mock()
+
+ # Transition TECHNICALLY_VALIDATED -> PENDING_PFA
+ # Upload validated budget file
+ data = {"status": EAPStatus.PENDING_PFA}
+ upload_url = f"/api/v2/eap-registration/{eap_registration.id}/upload-validated-budget-file/"
+ with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file:
+ tmp_file.write(b"Test content")
+ tmp_file.seek(0)
+ file_data = {"validated_budget_file": tmp_file}
+ self.authenticate(self.ifrc_admin_user)
+ response = self.client.post(upload_url, file_data, format="multipart")
+ self.assert_200(response)
+
+ # Now change status → PENDING_PFA
+ status_url = f"/api/v2/eap-registration/{eap_registration.id}/status/"
+ data = {"status": EAPStatus.PENDING_PFA}
+
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(status_url, data, format="json")
+ self.assert_200(response)
+ self.assertEqual(response.data["status"], EAPStatus.PENDING_PFA)
+ eap_registration.refresh_from_db()
+ send_pending_pfa_email.delay.assert_called_once_with(eap_registration.id)
+
+ # Transition PENDING_PFA -> APPROVED
+ data = {"status": EAPStatus.APPROVED}
+ with self.capture_on_commit_callbacks(execute=True):
+ response = self.client.post(url, data, format="json")
+ self.assert_200(response)
+ self.assertEqual(response.data["status"], EAPStatus.APPROVED)
+ eap_registration.refresh_from_db()
+ send_approved_email.delay.assert_called_once_with(eap_registration.id)
+
class EAPPDFExportTestCase(APITestCase):
def setUp(self):
diff --git a/eap/utils.py b/eap/utils.py
index a29498778..463af0764 100644
--- a/eap/utils.py
+++ b/eap/utils.py
@@ -7,6 +7,7 @@
from django.db import models
from api.models import Region, RegionName
+from eap.models import EAPType, FullEAP, SimplifiedEAP
REGION_EMAIL_MAP: dict[RegionName, list[str]] = {
RegionName.AFRICA: settings.EMAIL_EAP_AFRICA_COORDINATORS,
@@ -32,7 +33,11 @@ def get_coordinator_emails_by_region(region: Region | None) -> list[str]:
return REGION_EMAIL_MAP.get(region.name, [])
-def get_eap_registration_email_context(instance):
+# TODO @sudip-khanal: Add files to email context after implementing file sending in email notification
+# also include the deadline once it's added to the model.
+
+
+def get_eap_email_context(instance):
from eap.serializers import EAPRegistrationSerializer
eap_registration_data = EAPRegistrationSerializer(instance).data
@@ -48,7 +53,31 @@ def get_eap_registration_email_context(instance):
"ns_contact_email": eap_registration_data["national_society_contact_email"],
"ns_contact_phone": eap_registration_data["national_society_contact_phone_number"],
"frontend_url": settings.GO_WEB_URL,
+ # "review_checklist_file":eap_registration_data["review_checklist_file"],
+ # "validated_budget_file":eap_registration_data["validated_budget_file"],
}
+
+ if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ latest_eap_data = instance.latest_simplified_eap
+ latest_version = instance.latest_simplified_eap.version
+ qs = SimplifiedEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first()
+ previous_version = qs.version if qs else None
+ else:
+ latest_eap_data = instance.latest_full_eap
+ latest_version = instance.latest_full_eap
+ qs = FullEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first()
+ previous_version = qs.version if qs else None
+
+ email_context.update(
+ {
+ "people_targeted": latest_eap_data.people_targeted,
+ "total_budget": latest_eap_data.total_budget,
+ "latest_version": latest_eap_data.version,
+ "previous_version": previous_version,
+ # "updated_checklist_file": latest_eap_data.updated_checklist_file,
+ # "budget_file":latest_eap_data.budget_file,
+ }
+ )
return email_context
diff --git a/notifications/templates/email/eap/feedback_to_national_society.html b/notifications/templates/email/eap/feedback_to_national_society.html
index d11beab0d..1e60ac8b3 100644
--- a/notifications/templates/email/eap/feedback_to_national_society.html
+++ b/notifications/templates/email/eap/feedback_to_national_society.html
@@ -33,17 +33,17 @@
Attachments:
- Review checklist uploaded by IFRC
+ Review checklist uploaded by IFRC {{ review_checklist_file }}
Kind regards,
IFRC-DREF AA Team
- You can access your GO account and check the progress of your EAP here.
+ You can access your GO account and check the progress of your EAP here.
-{% include "design/foot1.html" %}
+{% include "design/foot1.html" %}
\ No newline at end of file
diff --git a/notifications/templates/email/eap/feedback_to_revised_eap.html b/notifications/templates/email/eap/feedback_to_revised_eap.html
index d0c0793bb..0dbd98177 100644
--- a/notifications/templates/email/eap/feedback_to_revised_eap.html
+++ b/notifications/templates/email/eap/feedback_to_revised_eap.html
@@ -10,7 +10,7 @@
- Thanks again for the submission of the {{ version }} version of this protocol.
+ Thanks again for the submission of the {{ previous_version }} version of this protocol.
We acknowledge the work the NS has done to submit it.
@@ -41,17 +41,17 @@
Kind regards,
IFRC-DREF AA Team
- You can access your GO account and check the progress of your EAP here.
+ You can access your GO account and check the progress of your EAP here.
Attached documents:
- Review checklist uploaded by IFRC
+ Review checklist uploaded by IFRC: {{ review_checklist_file }}
-{% include "design/foot1.html" %}
+{% include "design/foot1.html" %}
\ No newline at end of file
diff --git a/notifications/templates/email/eap/pending_pfa.html b/notifications/templates/email/eap/pending_pfa.html
index 0ae1ec304..13019a151 100644
--- a/notifications/templates/email/eap/pending_pfa.html
+++ b/notifications/templates/email/eap/pending_pfa.html
@@ -21,7 +21,18 @@
If you have any questions on the process or the next steps, please don’t hesitate to reach out to
DREF.anticipatorypillar@ifrc.org.
-
+
+
+ Attached documents:
+ Export Pdf:
+ Validated budget file:
+ {% if validated_budget_file %}
+ {{ validated_budget_file }}
+ {% else %}
+ N/A
+ {% endif %}
+
+
Congratulations again and warm wishes,
IFRC-DREF AA Team
@@ -30,4 +41,4 @@
-{% include "design/foot1.html" %}
+{% include "design/foot1.html" %}
\ No newline at end of file
diff --git a/notifications/templates/email/eap/re-submission.html b/notifications/templates/email/eap/re-submission.html
index dda0ab01b..a6a9e0b7c 100644
--- a/notifications/templates/email/eap/re-submission.html
+++ b/notifications/templates/email/eap/re-submission.html
@@ -6,7 +6,7 @@
Dear colleagues,
{{ national_society }}
- is hereby submitting the {{ version }} version of {{ national_society }} {{ disaster_type }} EAP to the IFRC-DREF.
+ is hereby submitting the {{ latest_version }} version of {{ national_society }} {{ disaster_type }} EAP to the IFRC-DREF.
@@ -41,7 +41,7 @@
People targeted
- {{ people_targated }}
+ {{ people_targeted }}
|
@@ -82,8 +82,13 @@
Attachments:
Revised EAP PDF with modifications in tracked changes:
- Revised EAP budget:
- Review checklist with National Society response to comments:
+ Revised EAP budget: {{ budget_file }}
+ Review checklist with National Society response to comments:
+ {% if updated_checklist_file %}
+ {{ updated_checklist_file }}"
+ {% else %}
+ N/A
+ {% endif %}
@@ -97,4 +102,4 @@
-{% include "design/foot1.html" %}
+{% include "design/foot1.html" %}
\ No newline at end of file
diff --git a/notifications/templates/email/eap/submission.html b/notifications/templates/email/eap/submission.html
index 28fc68800..14a9a6063 100644
--- a/notifications/templates/email/eap/submission.html
+++ b/notifications/templates/email/eap/submission.html
@@ -20,7 +20,7 @@
| Type of EAP |
- {{ eap_type_display|default:"Not Sure" }} |
+ {{ eap_type_display }} |
| Hazard |
@@ -28,7 +28,7 @@
| People targeted |
- 100 |
+ {{ people_targeted }} |
| Total Budget |
@@ -59,8 +59,8 @@
Attached documents:
- Export Pdf:
- budget file:
+ Export Pdf:
+ budget file: {{ budget_file }}
@@ -75,4 +75,4 @@
-{% include "design/foot1.html" %}
+{% include "design/foot1.html" %}
\ No newline at end of file
diff --git a/notifications/templates/email/eap/technically_validated_eap.html b/notifications/templates/email/eap/technically_validated_eap.html
index fa9cd7d38..e395e0209 100644
--- a/notifications/templates/email/eap/technically_validated_eap.html
+++ b/notifications/templates/email/eap/technically_validated_eap.html
@@ -28,13 +28,13 @@
Congratulations again and warm wishes,
IFRC-DREF AA Team
- You can access your GO account and check the progress of your EAP here.
+ You can access your GO account and check the progress of your EAP here.
Attachments:
- Review Checklist
+ Review Checklist {{ review_checklist_file }}
From 497a2502433eff87df16ae18627b4cd67be6a008 Mon Sep 17 00:00:00 2001
From: sudip-khanal
Date: Fri, 19 Dec 2025 12:07:56 +0545
Subject: [PATCH 2/2] fixup! feat(eap): Add EAP email workflow for different
status transition
---
eap/serializers.py | 6 ++++--
eap/tasks.py | 16 ++++++++--------
eap/utils.py | 4 ++--
3 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/eap/serializers.py b/eap/serializers.py
index 9e3059909..014bf764e 100644
--- a/eap/serializers.py
+++ b/eap/serializers.py
@@ -925,7 +925,9 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any
return updated_instance
eap_registration_id = updated_instance.id
- if updated_instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ assert updated_instance.get_eap_type_enum is not None, "EAP type must not be None"
+
+ if updated_instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
eap_count = SimplifiedEAP.objects.filter(eap_registration=updated_instance).count()
else:
eap_count = FullEAP.objects.filter(eap_registration=updated_instance).count()
@@ -963,7 +965,7 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any
if eap_count == 2:
transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id))
- else:
+ elif eap_count > 2:
transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id))
elif (old_status, new_status) == (
diff --git a/eap/tasks.py b/eap/tasks.py
index a4999f69e..819ae4ddd 100644
--- a/eap/tasks.py
+++ b/eap/tasks.py
@@ -61,7 +61,7 @@ def send_new_eap_submission_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
else:
@@ -108,7 +108,7 @@ def send_feedback_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
ifrc_delegation_focal_point_email = latest_simplified_eap.ifrc_delegation_focal_point_email
@@ -159,7 +159,7 @@ def send_eap_resubmission_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
latest_version = latest_simplified_eap.version
@@ -210,7 +210,7 @@ def send_feedback_email_for_resubmitted_eap(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
partner_ns_email = instance.latest_simplified_eap.partner_ns_email
latest_version = instance.latest_simplified_eap.version
qs = SimplifiedEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first()
@@ -263,7 +263,7 @@ def send_technical_validation_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
else:
@@ -307,7 +307,7 @@ def send_pending_pfa_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
else:
@@ -351,7 +351,7 @@ def send_approved_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
email_context = "Simplified EAP"
@@ -397,7 +397,7 @@ def send_deadline_reminder_email(eap_registration_id: int):
if not instance:
return None
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_simplified_eap = instance.latest_simplified_eap
partner_ns_email = latest_simplified_eap.partner_ns_email
else:
diff --git a/eap/utils.py b/eap/utils.py
index 463af0764..83c4c1c76 100644
--- a/eap/utils.py
+++ b/eap/utils.py
@@ -34,7 +34,7 @@ def get_coordinator_emails_by_region(region: Region | None) -> list[str]:
# TODO @sudip-khanal: Add files to email context after implementing file sending in email notification
-# also include the deadline once it's added to the model.
+# also include the deadline field once it added to the model.
def get_eap_email_context(instance):
@@ -57,7 +57,7 @@ def get_eap_email_context(instance):
# "validated_budget_file":eap_registration_data["validated_budget_file"],
}
- if instance.eap_type == EAPType.SIMPLIFIED_EAP:
+ if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
latest_eap_data = instance.latest_simplified_eap
latest_version = instance.latest_simplified_eap.version
qs = SimplifiedEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first()