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()