Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule "assets"]
path = assets
url = https://github.com/IFRCGo/go-api-artifacts
url = git@github.com:IFRCGo/go-api-artifacts.git
2 changes: 1 addition & 1 deletion assets
Submodule assets updated 1 files
+45 −0 openapi-schema.yaml
2 changes: 2 additions & 0 deletions deploy/helm/ifrcgo-helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
118 changes: 118 additions & 0 deletions eap/dev_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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,
"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": {
"eap_type_display": "FULL EAP",
"country_name": "Test Country",
"national_society": "Test National Society",
},
"resubmission_of_revised_eap": {
"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": {
"eap_type_display": "FULL EAP",
"country_name": "Test Country",
"national_society": "Test National Society",
"version": 2,
},
"technically_validated_eap": {
"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))
34 changes: 34 additions & 0 deletions eap/management/commands/eap_submission_reminder.py
Original file line number Diff line number Diff line change
@@ -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."))
23 changes: 23 additions & 0 deletions eap/migrations/0014_eapregistration_deadline_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.26 on 2025-12-24 05:46

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('eap', '0013_alter_eapregistration_national_society_contact_email_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'),
),
]
15 changes: 15 additions & 0 deletions eap/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,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
Expand Down
129 changes: 129 additions & 0 deletions eap/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import typing
from datetime import timedelta

from django.contrib.auth.models import User
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext
from rest_framework import serializers
Expand Down Expand Up @@ -32,6 +34,16 @@
TimeFrame,
YearsTimeFrameChoices,
)
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,
Expand Down Expand Up @@ -182,8 +194,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:
Expand Down Expand Up @@ -893,3 +916,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
Loading