diff --git a/assets b/assets index a9028dac0..89f0cafef 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit a9028dac02b82d93913cd1fab58b3ed679672602 +Subproject commit 89f0cafef25573986740aaee3a4ca6c224f87309 diff --git a/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 5177d8948..bca0bf244 100644 --- a/deploy/helm/ifrcgo-helm/values.yaml +++ b/deploy/helm/ifrcgo-helm/values.yaml @@ -279,6 +279,8 @@ cronjobs: # https://github.com/jazzband/django-oauth-toolkit/blob/master/docs/management_commands.rst#cleartokens - command: 'oauth_cleartokens' schedule: '0 1 * * *' + - command: 'eap_submission_reminder' + schedule: '0 0 * * *' elasticsearch: diff --git a/eap/dev_views.py b/eap/dev_views.py new file mode 100644 index 000000000..aa7c9617c --- /dev/null +++ b/eap/dev_views.py @@ -0,0 +1,123 @@ +from django.http import HttpResponse +from django.template import loader +from rest_framework import permissions +from rest_framework.views import APIView + + +class EAPEmailPreview(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + type_param = request.GET.get("type") + + template_map = { + "registration": "email/eap/registration.html", + "submission": "email/eap/submission.html", + "feedback_to_national_society": "email/eap/feedback_to_national_society.html", + "resubmission_of_revised_eap": "email/eap/re-submission.html", + "feedback_for_revised_eap": "email/eap/feedback_to_revised_eap.html", + "technically_validated_eap": "email/eap/technically_validated_eap.html", + "pending_pfa": "email/eap/pending_pfa.html", + "approved_eap": "email/eap/approved.html", + "reminder": "email/eap/reminder.html", + } + + if type_param not in template_map: + valid_values = ", ".join(template_map.keys()) + return HttpResponse( + f"Invalid 'type' parameter. Please use one of the following values: {valid_values}.", + ) + + context_map = { + "registration": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "supporting_partners": [ + {"society_name": "Partner 1"}, + {"society_name": "Partner 2"}, + ], + "disaster_type": "Flood", + "ns_contact_name": "Test registration name", + "ns_contact_email": "test.registration@example.com", + "ns_contact_phone": "1234567890", + }, + "submission": { + "eap_type_display": "SIMPLIFIED EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "people_targated": 100, + "latest_eap_id": 1, + "supporting_partners": [ + {"society_name": "Partner NS 1"}, + {"society_name": "Partner NS 2"}, + ], + "disaster_type": "Flood", + "total_budget": "250,000 CHF", + "ns_contact_name": "Test Ns Contact name", + "ns_contact_email": "test.Ns@gmail.com", + "ns_contact_phone": "+977-9800000000", + }, + "feedback_to_national_society": { + "registration_id": 1, + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + }, + "resubmission_of_revised_eap": { + "latest_eap_id": 1, + "eap_type_display": "SIMPLIFIED EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "supporting_partners": [ + {"society_name": "Partner NS 1"}, + {"society_name": "Partner NS 2"}, + ], + "version": 2 or 3, + "people_targated": 100, + "disaster_type": "Flood", + "total_budget": "250,000 CHF", + "ns_contact_name": "Test Ns Contact name", + "ns_contact_email": "test.Ns@gmail.com", + "ns_contact_phone": "+977-9800000000", + }, + "feedback_for_revised_eap": { + "registration_id": 1, + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "version": 2, + }, + "technically_validated_eap": { + "registration_id": 1, + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "pending_pfa": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "approved_eap": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "reminder": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + } + + context = context_map.get(type_param) + if context is None: + return HttpResponse("No context found for the email preview.") + template_file = template_map[type_param] + template = loader.get_template(template_file) + return HttpResponse(template.render(context, request)) diff --git a/eap/management/commands/eap_submission_reminder.py b/eap/management/commands/eap_submission_reminder.py new file mode 100644 index 000000000..fa0a47e67 --- /dev/null +++ b/eap/management/commands/eap_submission_reminder.py @@ -0,0 +1,34 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone +from sentry_sdk.crons import monitor + +from eap.models import EAPRegistration +from eap.tasks import send_deadline_reminder_email +from main.sentry import SentryMonitor + + +class Command(BaseCommand): + help = "Send EAP submission reminder emails 1 week before deadline" + + @monitor(monitor_slug=SentryMonitor.EAP_SUBMISSION_REMINDER) + def handle(self, *args, **options): + """ + Finds EAP-registrations whose submission deadline is exactly 1 week from today + and sends reminder emails for each matching registration. + """ + target_date = timezone.now().date() + timedelta(weeks=1) + queryset = EAPRegistration.objects.filter( + deadline=target_date, + ) + + if not queryset.exists(): + self.stdout.write(self.style.NOTICE("No EAP registrations found for deadline reminder.")) + return + + for instance in queryset.iterator(): + self.stdout.write(self.style.NOTICE(f"Sending deadline reminder email for EAPRegistration ID={instance.id}")) + send_deadline_reminder_email(instance.id) + + self.stdout.write(self.style.SUCCESS("Successfully sent all deadline reminder emails.")) diff --git a/eap/migrations/0015_eapregistration_deadline_and_more.py b/eap/migrations/0015_eapregistration_deadline_and_more.py new file mode 100644 index 000000000..5da8e7b97 --- /dev/null +++ b/eap/migrations/0015_eapregistration_deadline_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.26 on 2026-01-08 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0014_eapcontact_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='eapregistration', + name='deadline', + field=models.DateField(blank=True, help_text='Date by which the EAP submission must be completed.', null=True, verbose_name='deadline'), + ), + migrations.AddField( + model_name='eapregistration', + name='deadline_remainder_sent_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the deadline reminder email was sent.', null=True, verbose_name='deadline reminder email sent at'), + ), + ] diff --git a/eap/models.py b/eap/models.py index 04c8085c5..d57959470 100644 --- a/eap/models.py +++ b/eap/models.py @@ -722,6 +722,21 @@ class EAPRegistration(EAPBaseModel): help_text=_("Timestamp when the EAP was activated."), ) + # EAP submission deadline + deadline = models.DateField( + null=True, + blank=True, + verbose_name=_("deadline"), + help_text=_("Date by which the EAP submission must be completed."), + ) + + deadline_remainder_sent_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("deadline reminder email sent at"), + help_text=_("Timestamp when the deadline reminder email was sent."), + ) + # TYPING id: int national_society_id: int diff --git a/eap/serializers.py b/eap/serializers.py index fac1e7e4d..0dc131ed8 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,4 +1,5 @@ import typing +from datetime import timedelta from celery import group from django.contrib.auth.models import User @@ -39,6 +40,14 @@ generate_eap_summary_pdf, generate_export_diff_pdf, generate_export_eap_pdf, + 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, @@ -189,8 +198,19 @@ class Meta: "modified_by", "latest_simplified_eap", "latest_full_eap", + "deadline", ] + def create(self, validated_data: dict[str, typing.Any]): + instance = super().create(validated_data) + + transaction.on_commit( + lambda: send_new_eap_registration_email.delay( + instance.id, + ) + ) + return instance + def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: # NOTE: Cannot update once EAP application is being created. if instance.has_eap_application: @@ -962,3 +982,109 @@ 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 + 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() + + 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 + + Deadline update rules: + - First IFRC feedback cycle: deadline is set to 90 days from the current date. + - Subsequent feedback or resubmission cycles: deadline is set to 30 days from the current date. + """ + + if eap_count == 2: + updated_instance.deadline = timezone.now().date() + timedelta(days=90) + updated_instance.save( + update_fields=[ + "deadline", + ] + ) + transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id)) + + elif eap_count > 2: + updated_instance.deadline = timezone.now().date() + timedelta(days=30) + updated_instance.save( + update_fields=[ + "deadline", + ] + ) + 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, + ): + updated_instance.deadline = timezone.now().date() + timedelta(days=30) + updated_instance.save( + update_fields=[ + "deadline", + ] + ) + 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 481c724c9..2577e33a1 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -1,14 +1,23 @@ from datetime import datetime from celery import shared_task +from django.conf import settings from django.contrib.auth.models import User +from django.template.loader import render_to_string +from django.utils import timezone from rest_framework.authtoken.models import Token from api.logger import logger from api.playwright import render_pdf_from_url from api.utils import generate_eap_export_url from eap.models import EAPRegistration, EAPType, FullEAP, SimplifiedEAP +from eap.utils import ( + get_coordinator_emails_by_region, + get_eap_email_context, + get_eap_registration_email_context, +) from main.utils import logger_context +from notifications.notification import send_notification def build_filename(eap_registration: EAPRegistration) -> str: @@ -108,7 +117,6 @@ def generate_export_eap_pdf(eap_registration_id, version): eap_registration = EAPRegistration.objects.get(id=eap_registration_id) user = User.objects.get(id=eap_registration.created_by_id) token = Token.objects.filter(user=user).last() - url = generate_eap_export_url( registration_id=eap_registration_id, version=version, @@ -152,3 +160,456 @@ def generate_export_eap_pdf(eap_registration_id, version): dict(eap_registration_id=eap_registration.pk), ), ) + + +@shared_task +def send_new_eap_registration_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + 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( + [ + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_registration_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}" + ) + email_body = render_to_string("email/eap/registration.html", email_context) + email_type = "New EAP Registration" + + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + 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.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + if not latest_eap.export_file: + generate_export_eap_pdf( + eap_registration_id=instance.id, + version=latest_eap.version, + ) + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + 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( + [ + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + ifrc_delegation_focal_point_email = latest_eap.ifrc_delegation_focal_point_email + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + ifrc_delegation_focal_point_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + latest_version = latest_eap.version + + if not latest_eap.diff_file: + generate_export_diff_pdf( + eap_registration_id=instance.id, + version=latest_eap.version, + ) + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + 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( + [ + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + eap_model = SimplifiedEAP + else: + latest_eap = instance.latest_full_eap + eap_model = FullEAP + + latest_version = latest_eap.version + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + previous_eap = ( + eap_model.objects.filter( + eap_registration=instance, + version__lt=latest_version, + ) + .order_by("-version") + .first() + ) + + previous_version = previous_eap.version if previous_eap 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( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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 + + partner_contacts = ( + instance.latest_simplified_eap.partner_contacts + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP + else instance.latest_full_eap.partner_contacts + ) + + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + if not latest_eap.summary_file: + generate_eap_summary_pdf( + eap_registration_id=instance.id, + ) + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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 + + partner_contacts = ( + instance.latest_simplified_eap.partner_contacts + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP + else instance.latest_full_eap.partner_contacts + ) + + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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 + + partner_contacts = ( + instance.latest_simplified_eap.partner_contacts + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP + else instance.latest_full_eap.partner_contacts + ) + + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_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/reminder.html", email_context) + email_type = "Approved EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + instance.deadline_remainder_sent_at = timezone.now() + instance.save(update_fields=["deadline_remainder_sent_at"]) + + return email_context diff --git a/eap/test_views.py b/eap/test_views.py index a20688f86..f4f76718d 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -122,7 +122,8 @@ def test_list_eap_registration(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["results"]), 5) - def test_create_eap_registration(self): + @mock.patch("eap.tasks.send_new_eap_registration_email") + def test_create_eap_registration(self, send_new_eap_registration_email): url = "/api/v2/eap-registration/" data = { "eap_type": EAPType.FULL_EAP, @@ -159,6 +160,7 @@ def test_create_eap_registration(self): self.disaster_type.id, }, ) + self.assertTrue(send_new_eap_registration_email) def test_retrieve_eap_registration(self): eap_registration = EAPRegistrationFactory.create( @@ -1653,6 +1655,304 @@ def test_status_transition(self): response = self.client.patch(url, update_data, format="json") self.assertEqual(response.status_code, 400) + @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.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.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.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 0a1806a9f..04f25d5fa 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,10 +1,129 @@ import os from typing import Any, Dict, Set, TypeVar +from django.conf import settings from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError 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, + RegionName.AMERICAS: settings.EMAIL_EAP_AMERICAS_COORDINATORS, + RegionName.ASIA_PACIFIC: settings.EMAIL_EAP_ASIA_PACIFIC_COORDINATORS, + RegionName.EUROPE: settings.EMAIL_EAP_EUROPE_COORDINATORS, + RegionName.MENA: settings.EMAIL_EAP_MENA_COORDINATORS, +} + + +def get_coordinator_emails_by_region(region: Region | None) -> list[str]: + """ + This function uses the REGION_EMAIL_MAP dictionary to map Region name to the corresponding list of email addresses. + Args: + region: Region instance for which the coordinator emails are needed. + Returns: + List of email addresses corresponding to the region coordinators. + Returns an empty list if the region is None or not found in the mapping. + """ + if not region: + return [] + + return REGION_EMAIL_MAP.get(region.name, []) + + +def get_file_url(file_obj): + """ + This function returns the URL of a file field if it exists. + Args: + file_obj: A model instance or object containing a file field. + Returns: + str | None: The URL of the file if available, otherwise None. + """ + if not file_obj: + return None + if hasattr(file_obj, "file"): + return file_obj.file.url + + +def get_eap_registration_email_context(instance): + from eap.serializers import EAPRegistrationSerializer + + eap_registration_data = EAPRegistrationSerializer(instance).data + email_context = { + "registration_id": eap_registration_data["id"], + "eap_type_display": eap_registration_data["eap_type_display"], + "country_name": eap_registration_data["country_details"]["name"], + "national_society": eap_registration_data["national_society_details"]["society_name"], + "supporting_partners": eap_registration_data["partners_details"], + "disaster_type": eap_registration_data["disaster_type_details"]["name"], + "ns_contact_name": eap_registration_data["national_society_contact_name"], + "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, + } + return email_context + + +def get_eap_email_context(instance): + from eap.serializers import EAPRegistrationSerializer + + eap_registration_data = EAPRegistrationSerializer(instance).data + email_context = { + "registration_id": eap_registration_data["id"], + "eap_type_display": eap_registration_data["eap_type_display"], + "country_name": eap_registration_data["country_details"]["name"], + "national_society": eap_registration_data["national_society_details"]["society_name"], + "supporting_partners": eap_registration_data["partners_details"], + "disaster_type": eap_registration_data["disaster_type_details"]["name"], + "ns_contact_name": eap_registration_data["national_society_contact_name"], + "ns_contact_email": eap_registration_data["national_society_contact_email"], + "ns_contact_phone": eap_registration_data["national_society_contact_phone_number"], + "deadline": eap_registration_data["deadline"], + "frontend_url": settings.GO_WEB_URL, + "validated_budget_file": (instance.validated_budget_file.url if instance.validated_budget_file else None), + "summary_file": (instance.summary_file.url if instance.summary_file else None), + } + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap_data = instance.latest_simplified_eap + eap_model = SimplifiedEAP + else: + latest_eap_data = instance.latest_full_eap + eap_model = FullEAP + + latest_version = latest_eap_data.version + + previous_eap = ( + eap_model.objects.filter( + eap_registration=instance, + version__lt=latest_version, + ) + .order_by("-version") + .first() + ) + + previous_version = previous_eap.version if previous_eap else None + + email_context.update( + { + "latest_eap_id": latest_eap_data.id, + "people_targeted": latest_eap_data.people_targeted, + "total_budget": latest_eap_data.total_budget, + "latest_version": latest_eap_data.version, + "previous_version": previous_version, + "export_file": (latest_eap_data.export_file.url if latest_eap_data.export_file else None), + "diff_file": (latest_eap_data.diff_file.url if latest_eap_data.diff_file else None), + "budget_file": get_file_url(latest_eap_data.budget_file), + "updated_checklist_file": get_file_url(latest_eap_data.updated_checklist_file), + "review_checklist_file": ( + latest_eap_data.review_checklist_file.url if latest_eap_data.review_checklist_file else None + ), + } + ) + return email_context + def has_country_permission(user: User, country_id: int) -> bool: """Checks if the user has country admin permission.""" diff --git a/main/sentry.py b/main/sentry.py index dcb18ee91..183d6c0b3 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -130,6 +130,7 @@ class SentryMonitor(models.TextChoices): INGEST_ICRC = "ingest_icrc", "0 3 * * 0" NOTIFY_VALIDATORS = "notify_validators", "0 0 * * *" OAUTH_CLEARTOKENS = "oauth_cleartokens", "0 1 * * *" + EAP_SUBMISSION_REMINDER = "eap_submission_reminder", "0 0 * * *" @staticmethod def load_cron_data() -> typing.List[typing.Tuple[str, str]]: diff --git a/main/settings.py b/main/settings.py index 6fede598b..2ff0776d6 100644 --- a/main/settings.py +++ b/main/settings.py @@ -68,6 +68,14 @@ EMAIL_USER=(str, None), EMAIL_PASS=(str, None), DEBUG_EMAIL=(bool, False), # This was 0/1 before + # EAP-EMAILS + EMAIL_EAP_DREF_ANTICIPATORY_PILLAR=(str, None), + EMAIL_EAP_DREF_AA_GLOBAL_TEAM=(list, None), + EMAIL_EAP_AFRICA_COORDINATORS=(list, None), + EMAIL_EAP_AMERICAS_COORDINATORS=(list, None), + EMAIL_EAP_ASIA_PACIFIC_COORDINATORS=(list, None), + EMAIL_EAP_EUROPE_COORDINATORS=(list, None), + EMAIL_EAP_MENA_COORDINATORS=(list, None), # TEST_EMAILS=(list, ['im@ifrc.org']), # maybe later # Translation # Translator Available: @@ -198,6 +206,7 @@ def parse_domain(*env_keys: str) -> str: ALLOWED_HOSTS = [ "localhost", "0.0.0.0", + "127.0.0.1", urlparse(GO_API_URL).hostname, *env("DJANGO_ADDITIONAL_ALLOWED_HOSTS"), ] @@ -581,6 +590,15 @@ def parse_domain(*env_keys: str) -> str: DEBUG_EMAIL = env("DEBUG_EMAIL") # TEST_EMAILS = env('TEST_EMAILS') # maybe later +# EAP-Email +EMAIL_EAP_DREF_ANTICIPATORY_PILLAR = env("EMAIL_EAP_DREF_ANTICIPATORY_PILLAR") +EMAIL_EAP_DREF_AA_GLOBAL_TEAM = env("EMAIL_EAP_DREF_AA_GLOBAL_TEAM") +EMAIL_EAP_AFRICA_COORDINATORS = env("EMAIL_EAP_AFRICA_COORDINATORS") +EMAIL_EAP_AMERICAS_COORDINATORS = env("EMAIL_EAP_AMERICAS_COORDINATORS") +EMAIL_EAP_ASIA_PACIFIC_COORDINATORS = env("EMAIL_EAP_ASIA_PACIFIC_COORDINATORS") +EMAIL_EAP_EUROPE_COORDINATORS = env("EMAIL_EAP_EUROPE_COORDINATORS") +EMAIL_EAP_MENA_COORDINATORS = env("EMAIL_EAP_MENA_COORDINATORS") + DATA_UPLOAD_MAX_MEMORY_SIZE = 104857600 # default 2621440, 2.5MB -> 100MB # default 1000, was not enough for Mozambique Cyclone Idai data # second 2000, was not enouch for Global COVID Emergency diff --git a/main/urls.py b/main/urls.py index da2d1a36d..59bb57bf2 100644 --- a/main/urls.py +++ b/main/urls.py @@ -57,6 +57,7 @@ from deployments import drf_views as deployment_views from dref import views as dref_views from eap import views as eap_views +from eap.dev_views import EAPEmailPreview from flash_update import views as flash_views from lang import views as lang_views from local_units import views as local_units_views @@ -288,6 +289,7 @@ # For django versions before 2.0: # url(r'^__debug__/', include(debug_toolbar.urls)), url(r"^dev/email-preview/local-units/", LocalUnitsEmailPreview.as_view()), + url(r"^dev/email-preview/eap/", EAPEmailPreview.as_view()), ] + urlpatterns + static.static( diff --git a/notifications/notification.py b/notifications/notification.py index 2e48d1e8f..d2532ac96 100644 --- a/notifications/notification.py +++ b/notifications/notification.py @@ -56,13 +56,14 @@ def run(self): CronJob.sync_cron(cron_rec) -def construct_msg(subject, html): +def construct_msg(cc_addresses, subject, html): msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = settings.EMAIL_USER.upper() msg["To"] = "no-reply@ifrc.org" - + if cc_addresses: + msg["Cc"] = ",".join(cc_addresses) text_body = MIMEText(strip_tags(html), "plain") html_body = MIMEText(html, "html") @@ -72,8 +73,9 @@ def construct_msg(subject, html): return msg -def send_notification(subject, recipients, html, mailtype="", files=None): +def send_notification(subject, recipients, html, mailtype="", cc_recipients=None, files=None): """Generic email sending method, handly only HTML emails currently""" + cc_recipients = cc_recipients or [] if not settings.EMAIL_USER or not settings.EMAIL_API_ENDPOINT: logger.warning("Cannot send notifications.\n" "No username and/or API endpoint set as environment variables.") if settings.DEBUG: @@ -81,6 +83,11 @@ def send_notification(subject, recipients, html, mailtype="", files=None): print(f"subject={subject}\nrecipients={recipients}\nhtml={html}\nmailtype={mailtype}") print("-" * 22, "EMAIL END -", "-" * 22) return + + to_addresses = recipients if isinstance(recipients, list) else [recipients] + cc_addresses = cc_recipients if isinstance(cc_recipients, list) else [cc_recipients] + addresses = to_addresses + cc_addresses + if settings.DEBUG_EMAIL: print("-" * 22, "EMAIL START", "-" * 22) print(f"\n{html}\n") @@ -88,15 +95,13 @@ def send_notification(subject, recipients, html, mailtype="", files=None): if settings.FORCE_USE_SMTP: logger.info("Forcing SMPT usage for sending emails.") - msg = construct_msg(subject, html) - SendMail(recipients, msg).start() + msg = construct_msg(cc_addresses, subject, html) + SendMail(addresses, msg).start() return if "?" not in settings.EMAIL_API_ENDPOINT: # a.k.a dirty disabling email sending return - to_addresses = recipients if isinstance(recipients, list) else [recipients] - # if not IS_PROD: # logger.info('Using test email addresses...') # to_addresses = [] @@ -116,6 +121,7 @@ def send_notification(subject, recipients, html, mailtype="", files=None): # to_addresses.append(eml) recipients_as_string = ",".join(to_addresses) + cc_recipients_as_string = ",".join(cc_addresses) if not recipients_as_string: if len(to_addresses) > 0: warn_msg = "Recipients failed to be converted to string, 1st rec.: {}".format(to_addresses[0]) @@ -131,7 +137,7 @@ def send_notification(subject, recipients, html, mailtype="", files=None): payload = { "FromAsBase64": str(base64.b64encode(settings.EMAIL_USER.encode("utf-8")), "utf-8"), "ToAsBase64": str(base64.b64encode(EMAIL_TO.encode("utf-8")), "utf-8"), - "CcAsBase64": "", + "CcAsBase64": str(base64.b64encode(cc_recipients_as_string.encode("utf-8")), "utf-8"), "BccAsBase64": str(base64.b64encode(recipients_as_string.encode("utf-8")), "utf-8"), "SubjectAsBase64": str(base64.b64encode(subject.encode("utf-8")), "utf-8"), "BodyAsBase64": str(base64.b64encode(html.encode("utf-8")), "utf-8"), @@ -154,7 +160,9 @@ def send_notification(subject, recipients, html, mailtype="", files=None): # Saving GUID into a table so that the API can be queried with it to get info about # if the actual sending has failed or not. NotificationGUID.objects.create( - api_guid=res_text, email_type=mailtype, to_list=f"To: {EMAIL_TO}; Bcc: {recipients_as_string}" + api_guid=res_text, + email_type=mailtype, + to_list=f"To: {EMAIL_TO}; Cc: {cc_recipients_as_string}; Bcc: {recipients_as_string}", ) logger.info("E-mails were sent successfully.") @@ -167,6 +175,6 @@ def send_notification(subject, recipients, html, mailtype="", files=None): ) # Try sending with Python smtplib, if reaching the API fails logger.warning(f"Authorization/authentication failed ({res.status_code}) to the e-mail sender API.") - msg = construct_msg(subject, html) - SendMail(to_addresses, msg).start() + msg = construct_msg(cc_addresses, subject, html) + SendMail(addresses, msg).start() return res.text diff --git a/notifications/templates/email/eap/approved.html b/notifications/templates/email/eap/approved.html new file mode 100644 index 000000000..b272a7964 --- /dev/null +++ b/notifications/templates/email/eap/approved.html @@ -0,0 +1,33 @@ +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+ +

+ We are glad to inform you that the {{ country_name }} {{ disaster_type }} is ready for implementation. Congratulations! +

+ +

+ The IFRC Project should ensure that the transfer of funds for year 1 is done as soon as possible + and the NS should start the implementation of readiness for year 1 and pre-positioning activities. +

+ +

+ If you have any questions on the process or the next steps, please don’t hesitate to reach out + DREF.anticipatorypillar@ifrc.org. +

+

+ Congratulations again and warm wishes,
+ IFRC DREF AA Team +

+ +
+ +{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/feedback_to_national_society.html b/notifications/templates/email/eap/feedback_to_national_society.html new file mode 100644 index 000000000..64b3eed9c --- /dev/null +++ b/notifications/templates/email/eap/feedback_to_national_society.html @@ -0,0 +1,51 @@ + +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+ + +

+ Thanks again for the submission of this protocol. We acknowledge the work the NS has done to submit it. + The Validation Committee, the Delegation, and the Regional colleagues have completed the review. + We are hereby sharing with you the compiled review checklist. +

+ + +

As next steps, the NS should:

+
    +
  • Answer all the comments in the “National Society response” cells (Columns H and I) and upload it in GO
  • +
  • Adjust the EAP narrative in GO as needed
  • +
  • Adjust the EAP budget as needed and upload it in GO
  • +
+ + +

+ The NS has 3 months to address these comments, which means that we expect to receive the new version + of the EAP no later than {{ deadline }} (3 months). + In case the NS has any questions about the feedback provided, we are available to organize a feedback call. + Do not hesitate to contact us should you have any further questions at DREF.anticipatorypillar@ifrc.org. +


+

You can access your GO account and check the progress of your EAP here.

+ +

Attachments:

+ {% if review_checklist_file %} + + {% endif %} + +

+ Kind regards,
+ IFRC-DREF AA Team
+

+
+ +{% 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 new file mode 100644 index 000000000..a74569826 --- /dev/null +++ b/notifications/templates/email/eap/feedback_to_revised_eap.html @@ -0,0 +1,57 @@ + +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+ + +

+ Thanks again for the submission of the {{ previous_version }} version of this protocol. + We acknowledge the work the NS has done to submit it. +

+ +

+ The Validation Committee, the Delegation, and the Regional colleagues have reviewed the answers you provided and the changes made to the narrative and budget. + However, there are remaining questions. Please find the review checklist attached. + You can find the pending questions in the respective columns. +

+ + +

As next steps, the NS should:

+
    +
  • Answer the remaining comments in the “National Society response” cells and upload it in GO
  • +
  • Adjust the EAP narrative in GO as needed
  • +
  • Adjust the EAP budget as needed and upload it in GO
  • +
+ + +

+ The NS has 1 month to address these comments, which means that we expect to receive the new version of the EAP no later than + {{ deadline }} (1 month). + In case the NS has any questions about the feedback provided, we are available to organize a feedback call. + Do not hesitate to contact us should you have any further questions at + DREF.anticipatorypillar@ifrc.org. +

+

You can access your GO account and check the progress of your EAP here.

+ +

Attached documents:

+ {% if review_checklist_file %} + + {% endif %} + +

+ Kind regards,
+ IFRC-DREF AA Team
+

+
+ +{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/pending_pfa.html b/notifications/templates/email/eap/pending_pfa.html new file mode 100644 index 000000000..ebbc4800c --- /dev/null +++ b/notifications/templates/email/eap/pending_pfa.html @@ -0,0 +1,48 @@ + +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+ + +

+ We are glad to inform you that the {{country_name}} {{disaster_type}} EAP has been approved by the DREF Appeal Manager. Congratulations! +

+ +

+ The IFRC Project should start the PFA process right away and upload the PFA in GO within the next 14 days. +

+ +

+ 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:

+ +

+ Congratulations again and warm wishes,
+ IFRC-DREF AA Team
+

+
+ +{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/re-submission.html b/notifications/templates/email/eap/re-submission.html new file mode 100644 index 000000000..03f5ea935 --- /dev/null +++ b/notifications/templates/email/eap/re-submission.html @@ -0,0 +1,114 @@ + +{% include "design/head3.html" %} + + + + + +
+

Dear colleagues,

+

+ {{ national_society }} + is hereby submitting the {{ latest_version }} version of {{ national_society }} {{ disaster_type }} EAP to the IFRC-DREF. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if supporting_partners %} + + + + + {% endif %} +
+ Country + + {{ country_name }} +
+ Type of EAP + + {{ eap_type_display|default:"Not Sure" }} +
+ Hazard + + {{ disaster_type }} +
+ People targeted + + {{ people_targeted }} +
+ Budget + + {{ total_budget }} +
+ NS contact Person + + {{ ns_contact_name }} / {{ ns_contact_email }} +
+ Supporting Partner(s) + + {% for partner in supporting_partners %} + {{ partner.society_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+ +

+ Our National Society has considered the comments from the technical review, + has adjusted the narrative EAP in GO, updated the budget and responded to the comments in the review checklist. + Find attached the documents. +

+ +

Attachments:

+ + +

+ Kind regards,
+ {{ ns_contact_name }}
+ {{ ns_contact_email }}
+ {{ ns_contact_phone }} +

+ +
+ +{% include "design/foot1.html" %} \ No newline at end of file diff --git a/notifications/templates/email/eap/registration.html b/notifications/templates/email/eap/registration.html new file mode 100644 index 000000000..21f9f9af8 --- /dev/null +++ b/notifications/templates/email/eap/registration.html @@ -0,0 +1,54 @@ +{% include "design/head3.html" %} + + + + + +
+ + +

+ Dear colleagues, +

+

+ {{ national_society }}, wishes to inform the IFRC-DREF Team that has started to work on the development of an + {{ eap_type_display|default:"EAP" }}. +

+ + + + + + + + + + + + + + + + {% if supporting_partners %} + + + + + {% endif %} +
Country{{ country_name }}
Type of EAP {{ eap_type_display|default:"Not Sure" }}
Hazard{{ disaster_type }}
Supporting Partner(s) + {% for partner in supporting_partners %} + {{ partner.society_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+ + +

You can check the progress of this EAP here.

+

+ Kind regards,
+ {{ ns_contact_name }}
+ {{ ns_contact_email }}
+ {{ ns_contact_phone }} +

+
+ +{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/reminder.html b/notifications/templates/email/eap/reminder.html new file mode 100644 index 000000000..47d635384 --- /dev/null +++ b/notifications/templates/email/eap/reminder.html @@ -0,0 +1,29 @@ +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+ +

+ This is a reminder that the next version of the {{ country_name }} {{ disaster_type }} should be submitted before {{ deadline }}. +

+ +

+ If you have any questions regarding the process or next steps, please do not hesitate to contact us at + DREF.anticipatorypillar@ifrc.org. +

+ +

+ Kind regards,
+ IFRC DREF AA Team +

+ +
+ +{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/submission.html b/notifications/templates/email/eap/submission.html new file mode 100644 index 000000000..2c91882de --- /dev/null +++ b/notifications/templates/email/eap/submission.html @@ -0,0 +1,84 @@ +{% include "design/head3.html" %} + + + + + +
+

Dear colleagues,

+

+ {{ national_society }} is hereby submiting the following {{ eap_type_display|default:"Not Sure" }} + to the IFRC-DREF for technical review and approval: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if supporting_partners %} + + + + + {% endif %} +
Country{{ country_name }}
Type of EAP{{ eap_type_display }}
Hazard{{ disaster_type }}
People targeted{{ people_targeted }}
Total Budget{{ total_budget }}
NS contact Person + {{ ns_contact_name }} / {{ ns_contact_email }} +
Supporting Partner(s) + {% for partner in supporting_partners %} + {{ partner.society_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+ +

+ Please proceed by sharing the attached narrative and budget for comments with the IFRC Delegation, + Regional Office and with the Validation Committee. +

+ Attached documents:
+ + +

+ Kind regards,
+ {{ ns_contact_name }}
+ {{ ns_contact_email }}
+ {{ ns_contact_phone }} +

+ +
+ +{% 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 new file mode 100644 index 000000000..65fb7adfa --- /dev/null +++ b/notifications/templates/email/eap/technically_validated_eap.html @@ -0,0 +1,38 @@ + +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+ + +

+ We are glad to inform you that the {{country_name}} {{disaster_type}} EAP has been Technically Validated. Congratulations! +

+

+ The Validation Committee expresses its thanks to {{ national_society }} and the IFRC delegation for all the work done in providing clear answers to all feedback. This is very much appreciated. +

+

+ In terms of next steps, we ask the IFRC Project Manager to get the technical validation of the budget. + Once the validated budget is uploaded in GO, we will process the approval by the DREF Appeal Manager. +

+ +

+ If you have any questions on the process or the next steps, please don’t hesitate to reach out to + DREF.anticipatorypillar@ifrc.org. +

+

You can access your GO account and check the progress of your EAP here.

+ +

+ Congratulations again and warm wishes,
+ IFRC-DREF AA Team
+

+
+ +{% include "design/foot1.html" %} \ No newline at end of file