diff --git a/api/migrations/0227_alter_export_export_type.py b/api/migrations/0227_alter_export_export_type.py new file mode 100644 index 000000000..c3f8c75a2 --- /dev/null +++ b/api/migrations/0227_alter_export_export_type.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.26 on 2026-01-14 08:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0226_nsdinitiativescategory_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="export", + name="export_type", + field=models.CharField( + choices=[ + ("dref-applications", "DREF Application"), + ("dref-operational-updates", "DREF Operational Update"), + ("dref-final-reports", "DREF Final Report"), + ("old-dref-final-reports", "Old DREF Final Report"), + ("per", "Per"), + ("simplified", "Simplified EAP"), + ("full", "Full EAP"), + ], + max_length=255, + verbose_name="Export Type", + ), + ), + ] diff --git a/api/models.py b/api/models.py index 9cfb096bc..9e6acc911 100644 --- a/api/models.py +++ b/api/models.py @@ -2560,6 +2560,8 @@ class ExportType(models.TextChoices): FINAL_REPORT = "dref-final-reports", _("DREF Final Report") OLD_FINAL_REPORT = "old-dref-final-reports", _("Old DREF Final Report") PER = "per", _("Per") + SIMPLIFIED_EAP = "simplified", _("Simplified EAP") + FULL_EAP = "full", _("Full EAP") export_id = models.IntegerField(verbose_name=_("Export Id")) export_type = models.CharField(verbose_name=_("Export Type"), max_length=255, choices=ExportType.choices) diff --git a/api/playwright.py b/api/playwright.py new file mode 100644 index 000000000..96dd97fef --- /dev/null +++ b/api/playwright.py @@ -0,0 +1,121 @@ +import json +import pathlib +import tempfile +import time + +from django.conf import settings +from django.core.files.base import ContentFile +from playwright.sync_api import sync_playwright + +from .utils import DebugPlaywright + +footer_template = """ + + """ # noqa + + +def build_storage_state(tmp_dir, user, token, language="en"): + temp_file = pathlib.Path(tmp_dir, "storage_state.json") + temp_file.touch() + + state = { + "origins": [ + { + "origin": settings.GO_WEB_INTERNAL_URL + "/", + "localStorage": [ + { + "name": "user", + "value": json.dumps( + { + "id": user.id, + "username": user.username, + "firstName": user.first_name, + "lastName": user.last_name, + "token": token.key, + } + ), + }, + {"name": "language", "value": json.dumps(language)}, + ], + } + ] + } + with open(temp_file, "w") as f: + json.dump(state, f) + return temp_file + + +def render_pdf_from_url( + *, + url: str, + user, + token, + language: str = "en", + timeout: int = 300_000, +): + """ + Renders a URL to PDF using Playwright. + Returns a Django ContentFile. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + storage_state = build_storage_state( + tmp_dir=tmp_dir, + user=user, + token=token, + language=language, + ) + + with sync_playwright() as playwright: + browser = playwright.chromium.connect(settings.PLAYWRIGHT_SERVER_URL) + + try: + context = browser.new_context(storage_state=storage_state) + page = context.new_page() + + if settings.DEBUG_PLAYWRIGHT: + DebugPlaywright.debug(page) + + page.goto(url, timeout=timeout) + time.sleep(5) + # NOTE: Use wait_for_load_state instead of sleep? + # page.wait_for_load_state("networkidle", timeout=timeout) + page.wait_for_selector( + "#pdf-preview-ready", + state="attached", + timeout=timeout, + ) + + pdf_bytes = page.pdf( + display_header_footer=True, + prefer_css_page_size=True, + print_background=True, + footer_template=footer_template, + header_template="

", + ) + finally: + browser.close() + + return ContentFile(pdf_bytes) diff --git a/api/serializers.py b/api/serializers.py index cd0ba0d73..900382bb7 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -11,10 +11,11 @@ from rest_framework import serializers # from api.utils import pdf_exporter -from api.tasks import generate_url -from api.utils import CountryValidator, RegionValidator +from api.tasks import generate_export_pdf +from api.utils import CountryValidator, RegionValidator, generate_eap_export_url from deployments.models import EmergencyProject, Personnel, PersonnelDeployment from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate +from eap.models import EAPRegistration, FullEAP, SimplifiedEAP from lang.models import String from lang.serializers import ModelSerializer from local_units.models import DelegationOffice @@ -371,12 +372,14 @@ class Admin2Serializer(GeoSerializerMixin, ModelSerializer): bbox = serializers.SerializerMethodField() centroid = serializers.SerializerMethodField() district_id = serializers.IntegerField(source="admin1.id", read_only=True) + district_name = serializers.CharField(source="admin1.name", read_only=True) class Meta: model = Admin2 fields = ( "id", "district_id", + "district_name", "name", "code", "bbox", @@ -387,10 +390,11 @@ class Meta: class MiniAdmin2Serializer(ModelSerializer): district_id = serializers.IntegerField(source="admin1.id", read_only=True) + district_name = serializers.CharField(source="admin1.name", read_only=True) class Meta: model = Admin2 - fields = ("id", "name", "code", "district_id") + fields = ("id", "name", "code", "district_id", "district_name") class MiniDistrictSerializer(ModelSerializer): @@ -2543,6 +2547,13 @@ class ExportSerializer(serializers.ModelSerializer): status_display = serializers.CharField(source="get_status_display", read_only=True) # NOTE: is_pga is used to determine if the export contains PGA or not is_pga = serializers.BooleanField(default=False, required=False, write_only=True) + # NOTE: diff is used to determine if the export is requested for diff view or not + # Currently only used for EAP exports + diff = serializers.BooleanField(default=False, required=False, write_only=True, help_text="Only applicable for EAP exports") + # NOTE: Version of a EAP export being requested, only applicable for full and simplified EAP exports + version = serializers.IntegerField(required=False, write_only=True, help_text="Only applicable for EAP exports") + # NOTE: Only for FUll eap export + summary = serializers.BooleanField(default=False, required=False, write_only=True, help_text="Only applicable for FUll EAP") class Meta: model = Export @@ -2554,10 +2565,12 @@ def validate_pdf_file(self, pdf_file): return pdf_file def create(self, validated_data): - language = django_get_language() export_id = validated_data.get("export_id") export_type = validated_data.get("export_type") country_id = validated_data.get("per_country") + version = validated_data.pop("version", None) + diff = validated_data.pop("diff", False) + summary = validated_data.pop("summary", False) if export_type == Export.ExportType.DREF: title = Dref.objects.filter(id=export_id).first().title elif export_type == Export.ExportType.OPS_UPDATE: @@ -2567,17 +2580,67 @@ def create(self, validated_data): elif export_type == Export.ExportType.PER: overview = Overview.objects.filter(id=export_id).first() title = f"{overview.country.name}-preparedness-{overview.get_phase_display()}" + elif export_type == Export.ExportType.SIMPLIFIED_EAP: + if version: + simplified_eap = SimplifiedEAP.objects.filter( + eap_registration=export_id, + version=version, + ).first() + if not simplified_eap: + raise serializers.ValidationError("No Simplified EAP found for the given EAP Registration ID and version") + else: + eap_registration = EAPRegistration.objects.filter(id=export_id).first() + if not eap_registration: + raise serializers.ValidationError("No EAP Registration found for the given ID") + + simplified_eap = eap_registration.latest_simplified_eap + if not simplified_eap: + serializers.ValidationError("No Simplified EAP found for the given EAP Registration ID") + + title = ( + f"{simplified_eap.eap_registration.national_society.name}-{simplified_eap.eap_registration.disaster_type.name}" + ) + elif export_type == Export.ExportType.FULL_EAP: + if version: + full_eap = FullEAP.objects.filter( + eap_registration=export_id, + version=version, + ).first() + if not full_eap: + raise serializers.ValidationError("No Full EAP found for the given EAP Registration ID and version") + else: + eap_registration = EAPRegistration.objects.filter(id=export_id).first() + if not eap_registration: + raise serializers.ValidationError("No EAP Registration found for the given ID") + + full_eap = eap_registration.latest_full_eap + if not full_eap: + serializers.ValidationError("No Full EAP found for the given EAP Registration ID") + + title = f"{full_eap.eap_registration.national_society.name}-{full_eap.eap_registration.disaster_type.name}" else: title = "Export" user = self.context["request"].user if export_type == Export.ExportType.PER: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/countries/{country_id}/{export_type}/{export_id}/export/" + + elif export_type in [ + Export.ExportType.SIMPLIFIED_EAP, + Export.ExportType.FULL_EAP, + ]: + validated_data["url"] = generate_eap_export_url( + registration_id=export_id, + version=version, + diff=diff, + summary=summary, + ) + else: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/{export_type}/{export_id}/export/" # Adding is_pga to the url - is_pga = validated_data.pop("is_pga") + is_pga = validated_data.pop("is_pga", False) if is_pga: validated_data["url"] += "?is_pga=true" validated_data["requested_by"] = user @@ -2587,7 +2650,8 @@ def create(self, validated_data): export.requested_at = timezone.now() export.save(update_fields=["status", "requested_at"]) - transaction.on_commit(lambda: generate_url.delay(export.url, export.id, user.id, title, language)) + language = django_get_language() + transaction.on_commit(lambda: generate_export_pdf.delay(export.id, title, language)) return export def update(self, instance, validated_data): diff --git a/api/tasks.py b/api/tasks.py index a0126abcc..411473b64 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -1,144 +1,55 @@ -import json -import pathlib -import tempfile -import time from datetime import datetime from celery import shared_task -from django.conf import settings from django.contrib.auth.models import User -from django.core.files.base import ContentFile from django.utils import timezone -from playwright.sync_api import sync_playwright from rest_framework.authtoken.models import Token +from api.playwright import render_pdf_from_url from main.utils import logger_context from .logger import logger from .models import Export -from .utils import DebugPlaywright -def build_storage_state(tmp_dir, user, token, language="en"): - temp_file = pathlib.Path(tmp_dir, "storage_state.json") - temp_file.touch() +def build_export_filename(export: Export, title: str) -> str: + timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S") - state = { - "origins": [ - { - "origin": settings.GO_WEB_INTERNAL_URL + "/", - "localStorage": [ - { - "name": "user", - "value": json.dumps( - { - "id": user.id, - "username": user.username, - "firstName": user.first_name, - "lastName": user.last_name, - "token": token.key, - } - ), - }, - {"name": "language", "value": json.dumps(language)}, - ], - } - ] + prefix_map = { + Export.ExportType.PER: "PER", + Export.ExportType.SIMPLIFIED_EAP: "SIMPLIFIED EAP", + Export.ExportType.FULL_EAP: "FULL EAP", } - with open(temp_file, "w") as f: - json.dump(state, f) - return temp_file + + prefix = prefix_map.get(export.export_type, "DREF") + return f"{prefix} {title} ({timestamp}).pdf" @shared_task -def generate_url(url, export_id, user, title, language): +def generate_export_pdf(export_id, title, set_user_language="en"): export = Export.objects.get(id=export_id) - user = User.objects.get(id=user) + user = User.objects.get(id=export.requested_by.id) token = Token.objects.filter(user=user).last() logger.info(f"Starting export: {export.pk}") - footer_template = """ - - """ # noqa: E501 - try: - with tempfile.TemporaryDirectory() as tmp_dir: - with sync_playwright() as p: - browser = p.chromium.connect(settings.PLAYWRIGHT_SERVER_URL) - # NOTE: DREF Export use the language from request - if export.export_type in [ - Export.ExportType.DREF, - Export.ExportType.OPS_UPDATE, - Export.ExportType.FINAL_REPORT, - ]: - storage_state = build_storage_state( - tmp_dir, - user, - token, - language, - ) - else: - # NOTE: Other Export types use default language (en) - storage_state = build_storage_state( - tmp_dir, - user, - token, - ) - context = browser.new_context(storage_state=storage_state) - page = context.new_page() - if settings.DEBUG_PLAYWRIGHT: - DebugPlaywright.debug(page) - # FIXME: Use of Timeout correct? - timeout = 300_000 # 5 min - page.goto(url, timeout=timeout) - time.sleep(5) - page.wait_for_selector("#pdf-preview-ready", state="attached", timeout=timeout) - if export.export_type == Export.ExportType.PER: - file_name = f'PER {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' - else: - file_name = f'DREF {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' - file = ContentFile( - page.pdf( - display_header_footer=True, - prefer_css_page_size=True, - print_background=True, - footer_template=footer_template, - header_template="

", - ) - ) - browser.close() - export.pdf_file.save(file_name, file) - export.status = Export.ExportStatus.COMPLETED - export.completed_at = timezone.now() - export.save( - update_fields=[ - "status", - "completed_at", - ] - ) + file = render_pdf_from_url( + url=export.url, + user=user, + token=token, + language=set_user_language, + ) + + file_name = build_export_filename(export, title) + export.pdf_file.save(file_name, file) + export.status = Export.ExportStatus.COMPLETED + export.completed_at = timezone.now() + export.save( + update_fields=[ + "status", + "completed_at", + ] + ) except Exception: logger.error( f"Failed to export PDF: {export.export_type}", diff --git a/api/test_views.py b/api/test_views.py index 75c56c4fe..a116754a0 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -1,3 +1,4 @@ +import datetime import re import uuid from unittest.mock import patch @@ -868,73 +869,81 @@ class AppealTest(APITestCase): fixtures = ["DisasterTypes"] def test_appeal_key_figure(self): - region1 = models.Region.objects.create(name=1) - region2 = models.Region.objects.create(name=2) - country1 = models.Country.objects.create(name="Nepal", iso3="NPL", region=region1) - country2 = models.Country.objects.create(name="India", iso3="IND", region=region2) - dtype1 = models.DisasterType.objects.get(pk=1) - dtype2 = models.DisasterType.objects.get(pk=2) - event1 = EventFactory.create( - name="test1", - dtype=dtype1, - ) - event2 = EventFactory.create(name="test0", dtype=dtype1, num_affected=10000, countries=[country1]) - event3 = EventFactory.create(name="test2", dtype=dtype2, num_affected=99999, countries=[country2]) - AppealFactory.create( - event=event1, - dtype=dtype1, - num_beneficiaries=9000, - amount_requested=10000, - amount_funded=1899999, - code=12, - start_date="2024-1-1", - end_date="2024-1-1", - atype=AppealType.APPEAL, - country=country1, - ) - AppealFactory.create( - event=event2, - dtype=dtype2, - num_beneficiaries=90023, - amount_requested=100440, - amount_funded=12299999, - code=123, - start_date="2024-2-2", - end_date="2024-2-2", - atype=AppealType.DREF, - country=country1, - ) - AppealFactory.create( - event=event3, - dtype=dtype2, - num_beneficiaries=91000, - amount_requested=10000888, - amount_funded=678888, - code=1234, - start_date="2024-3-3", - end_date="2024-3-3", - atype=AppealType.APPEAL, - country=country1, - ) - AppealFactory.create( - event=event3, - dtype=dtype2, - num_beneficiaries=91000, - amount_requested=10000888, - amount_funded=678888, - code=12345, - start_date="2024-4-4", - end_date="2024-4-4", - atype=AppealType.APPEAL, - country=country1, - ) - url = f"/api/v2/country/{country1.id}/figure/" - self.client.force_authenticate(self.user) - response = self.client.get(url) - self.assert_200(response) - self.assertIsNotNone(response.json()) - self.assertEqual(response.data["active_drefs"], 1) - self.assertEqual(response.data["active_appeals"], 3) + creation_time = datetime.datetime(2023, 1, 5, 17, 4, 42, tzinfo=datetime.timezone.utc) + view_time = datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + + with patch("django.utils.timezone.now") as mock_now: + mock_now.return_value = creation_time + region1 = models.Region.objects.create(name=1) + region2 = models.Region.objects.create(name=2) + country1 = models.Country.objects.create(name="Nepal", iso3="NPL", region=region1) + country2 = models.Country.objects.create(name="India", iso3="IND", region=region2) + dtype1 = models.DisasterType.objects.get(pk=1) + dtype2 = models.DisasterType.objects.get(pk=2) + event1 = EventFactory.create( + name="test1", + dtype=dtype1, + ) + event2 = EventFactory.create(name="test0", dtype=dtype1, num_affected=10000, countries=[country1]) + event3 = EventFactory.create(name="test2", dtype=dtype2, num_affected=99999, countries=[country2]) + AppealFactory.create( + event=event1, + dtype=dtype1, + num_beneficiaries=9000, + amount_requested=10000, + amount_funded=1899999, + code=12, + start_date="2024-1-1", + end_date="2024-1-1", + atype=AppealType.APPEAL, + country=country1, + ) + AppealFactory.create( + event=event2, + dtype=dtype2, + num_beneficiaries=90023, + amount_requested=100440, + amount_funded=12299999, + code=123, + start_date="2024-2-2", + end_date="2024-2-2", + atype=AppealType.DREF, + country=country1, + ) + AppealFactory.create( + event=event3, + dtype=dtype2, + num_beneficiaries=91000, + amount_requested=10000888, + amount_funded=678888, + code=1234, + start_date="2024-3-3", + end_date="2024-3-3", + atype=AppealType.APPEAL, + country=country1, + ) + AppealFactory.create( + event=event3, + dtype=dtype2, + num_beneficiaries=91000, + amount_requested=10000888, + amount_funded=678888, + code=12345, + start_date="2024-4-4", + end_date="2024-4-4", + atype=AppealType.APPEAL, + country=country1, + ) + + mock_now.return_value = view_time + url = f"/api/v2/country/{country1.id}/figure/" + self.client.force_authenticate(self.user) + response = self.client.get(url) + + self.assert_200(response) + self.assertIsNotNone(response.json()) + self.assertEqual(response.data["active_drefs"], 1) + self.assertEqual(response.data["active_appeals"], 3) class RegionSnippetVisibilityTest(APITestCase): diff --git a/api/utils.py b/api/utils.py index c0f43f674..9d8386d74 100644 --- a/api/utils.py +++ b/api/utils.py @@ -156,3 +156,40 @@ class RegionValidator(TypedDict): class CountryValidator(TypedDict): country: int local_unit_types: list[int] + + +def generate_eap_export_url( + registration_id: int, + version: Optional[int] = None, + diff: bool = False, + summary: bool = False, +) -> str: + """ + Generate EAP export URL for given registration ID, version and diff flag. + """ + from django.conf import settings + + from eap.models import EAPRegistration, EAPType + + registration = EAPRegistration.objects.filter(id=registration_id).first() + if not registration: + raise ValueError("EAP Registration with the given ID does not exist.") + + url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{registration_id}/export/" + if summary: + return url + "summary/" + + assert registration.get_eap_type_enum is not None, "EAP Type should not be None" + if registration.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + url += "simplified/" + else: + url += "full/" + + if version: + url += f"?version={version}" + + # NOTE: EAP exports with diff view only for EAPs exports + if diff: + url += "&diff=true" if version else "?diff=true" + + return url diff --git a/assets b/assets index aeda366d7..960b3601a 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit aeda366d7d172e5ed7eca71665937f2bc4fb0ec6 +Subproject commit 960b3601a961e07a552f384b24b5ab1041e4d033 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/admin.py b/eap/admin.py index 846f6b406..e6e7a107e 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1 +1,269 @@ -# Register your models here. +from django.contrib import admin +from django.db import transaction + +from eap.models import ( + EAPFile, + EAPRegistration, + EAPType, + FullEAP, + KeyActor, + SimplifiedEAP, +) + + +@admin.register(EAPFile) +class EAPFileAdmin(admin.ModelAdmin): + search_fields = ("caption",) + + +@admin.register(EAPRegistration) +class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): + list_select_related = True + search_fields = ( + "national_society__name", + "country__name", + "disaster_type__name", + ) + readonly_fields = ("summary_file",) + list_filter = ("eap_type",) + list_display = ( + "national_society_name", + "country", + "eap_type", + "disaster_type", + ) + autocomplete_fields = ( + "national_society", + "disaster_type", + "partners", + "created_by", + "modified_by", + ) + actions = [ + "regenerate_full_eap_summary", + ] + + def regenerate_full_eap_summary(self, request, queryset): + """ + Admin action to regenerate EAP summary PDF files for selected EAP registrations. + """ + from eap.tasks import generate_eap_summary_pdf + + for eap_registration in queryset: + if eap_registration.get_eap_type_enum != EAPType.FULL_EAP: + continue + transaction.on_commit(lambda: generate_eap_summary_pdf.delay(eap_registration.id)) + + regenerate_full_eap_summary.short_description = "Regenerate EAP summary PDF files for Full EAP" + + def national_society_name(self, obj): + return obj.national_society.society_name + + national_society_name.short_description = "National Society (NS)" + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + "national_society", + "country", + "disaster_type", + "created_by", + "modified_by", + ) + .prefetch_related( + "partners", + ) + ) + + +@admin.register(SimplifiedEAP) +class SimplifiedEAPAdmin(admin.ModelAdmin): + list_select_related = True + search_fields = ( + "eap_registration__country__name", + "eap_registration__disaster_type__name", + ) + list_display = ("simplifed_eap_application", "version", "is_locked") + autocomplete_fields = ( + "eap_registration", + "created_by", + "modified_by", + "admin2", + ) + readonly_fields = ( + "cover_image", + "partner_contacts", + "hazard_impact_images", + "risk_selected_protocols_images", + "selected_early_actions_images", + "planned_operations", + "enabling_approaches", + "parent", + "is_locked", + "version", + ) + actions = [ + "regenerate_diff_pdf_file", + "regenerate_export_eap_file", + ] + + def regenerate_export_eap_file(self, request, queryset): + """ + Admin action to regenerate EAP export files for selected Simplified EAP. + """ + from eap.tasks import generate_export_eap_pdf + + for simplified_eap in queryset: + transaction.on_commit( + lambda: generate_export_eap_pdf.delay( + eap_registration_id=simplified_eap.eap_registration.id, + version=simplified_eap.version, + ) + ) + + regenerate_export_eap_file.short_description = "Regenerate EAP export PDF files for selected Simplified EAPs" + + def regenerate_diff_pdf_file(self, request, queryset): + """ + Admin action to regenerate EAP diff PDF files for selected Simplified EAP. + """ + from eap.tasks import generate_export_diff_pdf + + for simplified_eap in queryset: + transaction.on_commit( + lambda: generate_export_diff_pdf.delay( + eap_registration_id=simplified_eap.eap_registration.id, + version=simplified_eap.version, + ) + ) + + regenerate_diff_pdf_file.short_description = "Regenerate EAP diff PDF files for selected Simplified EAPs" + + def simplifed_eap_application(self, obj): + return f"{obj.eap_registration.national_society.society_name} - {obj.eap_registration.disaster_type.name}" + + simplifed_eap_application.short_description = "Simplified EAP Application" + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + "created_by", + "modified_by", + "eap_registration__country", + "eap_registration__national_society", + "eap_registration__disaster_type", + ) + .prefetch_related( + "admin2", + "partner_contacts", + ) + ) + + +@admin.register(KeyActor) +class KeyActorAdmin(admin.ModelAdmin): + list_display = ("national_society",) + + +@admin.register(FullEAP) +class FullEAPAdmin(admin.ModelAdmin): + list_select_related = True + search_fields = ( + "eap_registration__country__name", + "eap_registration__disaster_type__name", + ) + list_display = ("eap_registration",) + autocomplete_fields = ( + "eap_registration", + "created_by", + "modified_by", + "admin2", + ) + readonly_fields = ( + "partner_contacts", + "cover_image", + "planned_operations", + "enabling_approaches", + "planned_operations", + "hazard_selection_images", + "theory_of_change_table_file", + "exposed_element_and_vulnerability_factor_images", + "prioritized_impact_images", + "risk_analysis_relevant_files", + "forecast_selection_images", + "definition_and_justification_impact_level_images", + "identification_of_the_intervention_area_images", + "trigger_model_relevant_files", + "early_action_selection_process_images", + "evidence_base_relevant_files", + "early_action_implementation_images", + "trigger_activation_system_images", + "activation_process_relevant_files", + "meal_relevant_files", + "capacity_relevant_files", + "forecast_table_file", + ) + actions = [ + "regenerate_diff_pdf_file", + "regenerate_export_eap_file", + ] + + def regenerate_export_eap_file(self, request, queryset): + """ + Admin action to regenerate EAP export PDF files for selected EAP registrations. + """ + from eap.tasks import generate_export_eap_pdf + + for full_eap in queryset: + transaction.on_commit( + lambda: generate_export_eap_pdf.delay( + eap_registration_id=full_eap.eap_registration.id, + version=full_eap.version, + ) + ) + + regenerate_export_eap_file.short_description = "Regenerate EAP export PDF files for selected Full EAPs" + + def regenerate_diff_pdf_file(self, request, queryset): + """ + Admin action to regenerate EAP diff PDF files for selected EAP registrations. + """ + from eap.tasks import generate_export_diff_pdf + + for full_eap in queryset: + transaction.on_commit( + lambda: generate_export_diff_pdf.delay( + eap_registration_id=full_eap.eap_registration.id, + version=full_eap.version, + ) + ) + + regenerate_diff_pdf_file.short_description = "Regenerate EAP diff PDF files for selected Full EAPs" + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + "created_by", + "modified_by", + "cover_image", + "eap_registration__country", + "eap_registration__national_society", + "eap_registration__disaster_type", + ) + .prefetch_related( + "admin2", + "partner_contacts", + "key_actors", + "risk_analysis_source_of_information", + "trigger_statement_source_of_information", + "trigger_model_source_of_information", + "evidence_base_source_of_information", + "activation_process_source_of_information", + ) + ) 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/enums.py b/eap/enums.py new file mode 100644 index 000000000..0cdf730fe --- /dev/null +++ b/eap/enums.py @@ -0,0 +1,13 @@ +from . import models + +enum_register = { + "eap_status": models.EAPStatus, + "eap_type": models.EAPType, + "sector": models.PlannedOperation.Sector, + "timeframe": models.TimeFrame, + "years_timeframe_value": models.YearsTimeFrameChoices, + "months_timeframe_value": models.MonthsTimeFrameChoices, + "days_timeframe_value": models.DaysTimeFrameChoices, + "hours_timeframe_value": models.HoursTimeFrameChoices, + "approach": models.EnablingApproach.Approach, +} diff --git a/eap/factories.py b/eap/factories.py new file mode 100644 index 000000000..3dbe3fb1e --- /dev/null +++ b/eap/factories.py @@ -0,0 +1,232 @@ +from datetime import datetime + +import factory +import pytz +from factory import fuzzy + +from eap.models import ( + EAPContact, + EAPFile, + EAPRegistration, + EAPStatus, + EAPType, + EnablingApproach, + FullEAP, + KeyActor, + OperationActivity, + PlannedOperation, + SimplifiedEAP, + TimeFrame, +) + + +class EAPFileFactory(factory.django.DjangoModelFactory): + class Meta: + model = EAPFile + + caption = fuzzy.FuzzyText(length=10, prefix="EAPFile-") + file = factory.django.FileField(filename="eap_file.txt") + + @classmethod + def _create_image(cls, *args, **kwargs) -> EAPFile: + return cls.create( + file=factory.django.FileField(filename="eap_image.jpeg", data=b"fake image data"), + caption="EAP Image", + **kwargs, + ) + + @classmethod + def _create_file(cls, *args, **kwargs) -> EAPFile: + return cls.create( + file=factory.django.FileField(filename="eap_document.pdf", data=b"fake pdf data"), + caption="EAP Document", + **kwargs, + ) + + +class EAPContactFactory(factory.django.DjangoModelFactory): + class Meta: + model = EAPContact + + title = fuzzy.FuzzyText(length=5, prefix="Title-") + name = fuzzy.FuzzyText(length=10, prefix="Contact-") + email = factory.LazyAttribute(lambda obj: f"{obj.name.lower()}@example.com") + phone_number = fuzzy.FuzzyText(length=10, prefix="12345") + + +class EAPRegistrationFactory(factory.django.DjangoModelFactory): + class Meta: + model = EAPRegistration + + status = fuzzy.FuzzyChoice(EAPStatus) + eap_type = fuzzy.FuzzyChoice(EAPType) + national_society_contact_name = fuzzy.FuzzyText(length=10, prefix="NS-") + national_society_contact_email = factory.LazyAttribute(lambda obj: f"{obj.national_society_contact_name.lower()}@example.com") + + @factory.post_generation + def partners(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for partner in extracted: + self.partners.add(partner) + + +class SimplifiedEAPFactory(factory.django.DjangoModelFactory): + class Meta: + model = SimplifiedEAP + + seap_timeframe = fuzzy.FuzzyInteger(2) + total_budget = fuzzy.FuzzyInteger(1000, 1000000) + readiness_budget = fuzzy.FuzzyInteger(1000, 1000000) + pre_positioning_budget = fuzzy.FuzzyInteger(1000, 1000000) + early_action_budget = fuzzy.FuzzyInteger(1000, 1000000) + people_targeted = fuzzy.FuzzyInteger(100, 100000) + seap_lead_timeframe_unit = fuzzy.FuzzyInteger(TimeFrame.MONTHS) + seap_lead_time = fuzzy.FuzzyInteger(1, 12) + operational_timeframe = fuzzy.FuzzyInteger(1, 12) + national_society_contact_name = fuzzy.FuzzyText(length=10, prefix="NS-") + national_society_contact_email = factory.LazyAttribute(lambda obj: f"{obj.national_society_contact_name.lower()}@example.com") + ifrc_delegation_focal_point_name = fuzzy.FuzzyText(length=10, prefix="IFRC-") + ifrc_delegation_focal_point_email = factory.LazyAttribute( + lambda obj: f"{obj.ifrc_delegation_focal_point_name.lower()}@example.com" + ) + ifrc_head_of_delegation_name = fuzzy.FuzzyText(length=10, prefix="ifrc-head-") + ifrc_head_of_delegation_email = factory.LazyAttribute(lambda obj: f"{obj.ifrc_head_of_delegation_name.lower()}@example.com") + + @factory.post_generation + def enabling_approaches(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for approach in extracted: + self.enabling_approaches.add(approach) + + @factory.post_generation + def planned_operations(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for operation in extracted: + self.planned_operations.add(operation) + + +class OperationActivityFactory(factory.django.DjangoModelFactory): + class Meta: + model = OperationActivity + + activity = fuzzy.FuzzyText(length=50, prefix="Activity-") + timeframe = fuzzy.FuzzyChoice(TimeFrame) + + +class EnablingApproachFactory(factory.django.DjangoModelFactory): + class Meta: + model = EnablingApproach + + approach = fuzzy.FuzzyChoice(EnablingApproach.Approach) + budget_per_approach = fuzzy.FuzzyInteger(1000, 1000000) + ap_code = fuzzy.FuzzyInteger(100, 999) + + @factory.post_generation + def readiness_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.readiness_activities.add(activity) + + @factory.post_generation + def prepositioning_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.prepositioning_activities.add(activity) + + @factory.post_generation + def early_action_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.early_action_activities.add(activity) + + +class PlannedOperationFactory(factory.django.DjangoModelFactory): + class Meta: + model = PlannedOperation + + sector = fuzzy.FuzzyChoice(PlannedOperation.Sector) + people_targeted = fuzzy.FuzzyInteger(100, 100000) + budget_per_sector = fuzzy.FuzzyInteger(1000, 1000000) + ap_code = fuzzy.FuzzyInteger(100, 999) + + @factory.post_generation + def readiness_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.readiness_activities.add(activity) + + @factory.post_generation + def prepositioning_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.prepositioning_activities.add(activity) + + @factory.post_generation + def early_action_activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.early_action_activities.add(activity) + + +class KeyActorFactory(factory.django.DjangoModelFactory): + class Meta: + model = KeyActor + + description = fuzzy.FuzzyText(length=5, prefix="KeyActor-") + + +class FullEAPFactory(factory.django.DjangoModelFactory): + class Meta: + model = FullEAP + + expected_submission_time = fuzzy.FuzzyDateTime(datetime(2025, 1, 1, tzinfo=pytz.utc)) + lead_time = fuzzy.FuzzyInteger(1, 100) + total_budget = fuzzy.FuzzyInteger(1000, 1000000) + readiness_budget = fuzzy.FuzzyInteger(1000, 1000000) + pre_positioning_budget = fuzzy.FuzzyInteger(1000, 1000000) + early_action_budget = fuzzy.FuzzyInteger(1000, 1000000) + people_targeted = fuzzy.FuzzyInteger(100, 100000) + national_society_contact_name = fuzzy.FuzzyText(length=10, prefix="NS-") + national_society_contact_email = factory.LazyAttribute(lambda obj: f"{obj.national_society_contact_name.lower()}@example.com") + ifrc_delegation_focal_point_name = fuzzy.FuzzyText(length=10, prefix="IFRC-") + ifrc_delegation_focal_point_email = factory.LazyAttribute( + lambda obj: f"{obj.ifrc_delegation_focal_point_name.lower()}@example.com" + ) + ifrc_head_of_delegation_name = fuzzy.FuzzyText(length=10, prefix="ifrc-head-") + ifrc_head_of_delegation_email = factory.LazyAttribute(lambda obj: f"{obj.ifrc_head_of_delegation_name.lower()}@example.com") + + @factory.post_generation + def key_actors(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for actor in extracted: + self.key_actors.add(actor) diff --git a/eap/filter_set.py b/eap/filter_set.py new file mode 100644 index 000000000..5e3ba16ac --- /dev/null +++ b/eap/filter_set.py @@ -0,0 +1,100 @@ +import django_filters as filters + +from api.models import Country, DisasterType +from eap.models import EAPRegistration, EAPStatus, EAPType, FullEAP, SimplifiedEAP + + +class BaseFilterSet(filters.FilterSet): + created_at = filters.DateFilter( + field_name="created_at", + lookup_expr="exact", + input_formats=["%Y-%m-%d"], + ) + created_at__lte = filters.DateFilter( + field_name="created_at", + lookup_expr="lte", + input_formats=["%Y-%m-%d"], + ) + created_at__gte = filters.DateFilter( + field_name="created_at", + lookup_expr="gte", + input_formats=["%Y-%m-%d"], + ) + + +class EAPRegistrationFilterSet(BaseFilterSet): + eap_type = filters.ChoiceFilter( + choices=EAPType.choices, + label="EAP Type", + ) + status = filters.ChoiceFilter( + choices=EAPStatus.choices, + label="EAP Status", + ) + + # Country + country = filters.ModelMultipleChoiceFilter( + field_name="country", + queryset=Country.objects.all(), + ) + national_society = filters.ModelMultipleChoiceFilter( + field_name="national_society", + queryset=Country.objects.all(), + ) + region = filters.NumberFilter( + field_name="country__region_id", + label="Region", + ) + partners = filters.ModelMultipleChoiceFilter( + field_name="partners", + queryset=Country.objects.all(), + ) + + # Disaster + disaster_type = filters.ModelMultipleChoiceFilter( + field_name="disaster_type", + queryset=DisasterType.objects.all(), + ) + + class Meta: + model = EAPRegistration + fields = () + + +class BaseEAPFilterSet(BaseFilterSet): + eap_registration = filters.ModelMultipleChoiceFilter( + field_name="eap_registration", + queryset=EAPRegistration.objects.all(), + ) + + seap_timeframe = filters.NumberFilter( + field_name="seap_timeframe", + label="SEAP Timeframe (in Years)", + ) + + national_society = filters.ModelMultipleChoiceFilter( + field_name="eap_registration__national_society", + queryset=Country.objects.all(), + ) + + country = filters.ModelMultipleChoiceFilter( + field_name="eap_registration__country", + queryset=Country.objects.all(), + ) + + disaster_type = filters.ModelMultipleChoiceFilter( + field_name="eap_registration__disaster_type", + queryset=DisasterType.objects.all(), + ) + + +class SimplifiedEAPFilterSet(BaseEAPFilterSet, BaseFilterSet): + class Meta: + model = SimplifiedEAP + fields = ("eap_registration",) + + +class FullEAPFilterSet(BaseEAPFilterSet): + class Meta: + model = FullEAP + fields = ("eap_registration",) 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/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py b/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py new file mode 100644 index 000000000..c0cd6d48b --- /dev/null +++ b/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py @@ -0,0 +1,2321 @@ +# Generated by Django 4.2.26 on 2026-01-16 06:27 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import main.fields + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0227_alter_export_export_type"), + ("eap", "0002_auto_20220708_0747"), + ] + + operations = [ + migrations.CreateModel( + name="EAPAction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "action", + models.CharField(max_length=255, verbose_name="Early Action"), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": "Early Action", + "verbose_name_plural": "Early Actions", + }, + ), + migrations.CreateModel( + name="EAPContact", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="Contact Name")), + ( + "email", + models.EmailField(max_length=255, verbose_name="Contact Email"), + ), + ( + "title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Contact Title", + ), + ), + ( + "phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Contact Phone Number", + ), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": "EAP Contact", + "verbose_name_plural": "EAP Contacts", + }, + ), + migrations.CreateModel( + name="EAPFile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "modified_at", + models.DateTimeField(auto_now=True, verbose_name="modified at"), + ), + ( + "file", + main.fields.SecureFileField( + help_text="Upload EAP related file.", + upload_to="eap/files/", + verbose_name="file", + ), + ), + ("caption", models.CharField(blank=True, max_length=225, null=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + ( + "modified_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified_by", + to=settings.AUTH_USER_MODEL, + verbose_name="modified by", + ), + ), + ], + options={ + "verbose_name": "eap file", + "verbose_name_plural": "eap files", + "ordering": ["-id"], + }, + ), + migrations.CreateModel( + name="EAPImpact", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("impact", models.CharField(max_length=255, verbose_name="Impact")), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": " Impact", + "verbose_name_plural": "Expected Impacts", + }, + ), + migrations.CreateModel( + name="EAPRegistration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "modified_at", + models.DateTimeField(auto_now=True, verbose_name="modified at"), + ), + ( + "eap_type", + models.IntegerField( + blank=True, + choices=[(10, "Full EAP"), (20, "Simplified EAP")], + help_text="Select the type of EAP.", + null=True, + verbose_name="EAP Type", + ), + ), + ( + "status", + models.IntegerField( + choices=[ + (10, "Under Development"), + (20, "Under Review"), + (30, "NS Addressing Comments"), + (40, "Technically Validated"), + (50, "Pending PFA"), + (60, "Approved"), + (70, "Activated"), + ], + default=10, + help_text="Select the current status of the EAP development process.", + verbose_name="EAP Status", + ), + ), + ( + "expected_submission_time", + models.DateField( + blank=True, + help_text="Include the propose time of submission, accounting for the time it will take to deliver the application.Leave blank if not sure.", + null=True, + verbose_name="Expected submission time", + ), + ), + ( + "validated_budget_file", + main.fields.SecureFileField( + blank=True, + help_text="Upload the validated budget file once the EAP is technically validated.", + null=True, + upload_to="eap/files/validated_budgets/", + verbose_name="Validated Budget File", + ), + ), + ( + "summary_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="EAP Summary PDF", + ), + ), + ( + "national_society_contact_name", + models.CharField( + max_length=255, verbose_name="national society contact name" + ), + ), + ( + "national_society_contact_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="national society contact title", + ), + ), + ( + "national_society_contact_email", + models.CharField( + max_length=255, verbose_name="national society contact email" + ), + ), + ( + "national_society_contact_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="national society contact phone number", + ), + ), + ( + "ifrc_contact_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC contact name ", + ), + ), + ( + "ifrc_contact_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC contact email", + ), + ), + ( + "ifrc_contact_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC contact title", + ), + ), + ( + "ifrc_contact_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC contact phone number", + ), + ), + ( + "dref_focal_point_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="dref focal point name", + ), + ), + ( + "dref_focal_point_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Dref focal point email", + ), + ), + ( + "dref_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Dref focal point title", + ), + ), + ( + "dref_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Dref focal point phone number", + ), + ), + ( + "technically_validated_at", + models.DateTimeField( + blank=True, + help_text="Timestamp when the EAP was technically validated.", + null=True, + verbose_name="technically validated at", + ), + ), + ( + "approved_at", + models.DateTimeField( + blank=True, + help_text="Timestamp when the EAP was approved.", + null=True, + verbose_name="approved at", + ), + ), + ( + "pending_pfa_at", + models.DateTimeField( + blank=True, + help_text="Timestamp when the EAP was marked as pending PFA.", + null=True, + verbose_name="pending pfa at", + ), + ), + ( + "activated_at", + models.DateTimeField( + blank=True, + help_text="Timestamp when the EAP was activated.", + null=True, + verbose_name="activated at", + ), + ), + ( + "deadline", + models.DateField( + blank=True, + help_text="Date by which the EAP submission must be completed.", + null=True, + verbose_name="deadline", + ), + ), + ( + "deadline_remainder_sent_at", + models.DateTimeField( + blank=True, + help_text="Timestamp when the deadline reminder email was sent.", + null=True, + verbose_name="deadline reminder email sent at", + ), + ), + ( + "country", + models.ForeignKey( + help_text="The country will be pre-populated based on the NS selection, but can be adapted as needed.", + on_delete=django.db.models.deletion.CASCADE, + related_name="development_registration_eap_country", + to="api.country", + verbose_name="Country", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + ( + "disaster_type", + models.ForeignKey( + help_text="Select the disaster type for which the EAP is needed", + on_delete=django.db.models.deletion.PROTECT, + to="api.disastertype", + verbose_name="Disaster Type", + ), + ), + ], + options={ + "verbose_name": "Development Registration EAP", + "verbose_name_plural": "Development Registration EAPs", + "ordering": ["-id"], + }, + ), + migrations.CreateModel( + name="EnablingApproach", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "approach", + models.IntegerField( + choices=[ + (10, "Secretariat Services"), + (20, "National Society Strengthening"), + (30, "Partnership And Coordination"), + ], + verbose_name="Approach", + ), + ), + ( + "budget_per_approach", + models.IntegerField(verbose_name="Budget per approach (CHF)"), + ), + ( + "ap_code", + models.IntegerField(blank=True, null=True, verbose_name="AP Code"), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": "Enabling Approach", + "verbose_name_plural": "Enabling Approaches", + }, + ), + migrations.CreateModel( + name="Indicator", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "title", + models.CharField(max_length=255, verbose_name="Indicator Title"), + ), + ("target", models.IntegerField(verbose_name="Indicator Target")), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": "Indicator", + "verbose_name_plural": "Indicators", + }, + ), + migrations.CreateModel( + name="OperationActivity", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("activity", models.CharField(max_length=255, verbose_name="Activity")), + ( + "timeframe", + models.IntegerField( + choices=[ + (10, "Years"), + (20, "Months"), + (30, "Days"), + (40, "Hours"), + ], + verbose_name="Timeframe", + ), + ), + ( + "time_value", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), + size=None, + verbose_name="Activity time span", + ), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": "Operation Activity", + "verbose_name_plural": "Operation Activities", + }, + ), + migrations.CreateModel( + name="PlannedOperation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sector", + models.IntegerField( + choices=[ + (101, "Shelter"), + (102, "Settlement and Housing"), + (103, "Livelihoods"), + (104, "Protection, Gender and Inclusion"), + (105, "Health and Care"), + (106, "Risk Reduction"), + (107, "Climate Adaptation and Recovery"), + (108, "Multipurpose Cash"), + (109, "Water, Sanitation And Hygiene"), + (110, "WASH"), + (111, "Education"), + (112, "Migration"), + (113, "Environment Sustainability"), + (114, "Community Engagement And Accountability"), + ], + verbose_name="sector", + ), + ), + ( + "people_targeted", + models.IntegerField(verbose_name="People Targeted"), + ), + ( + "budget_per_sector", + models.IntegerField(verbose_name="Budget per sector (CHF)"), + ), + ( + "ap_code", + models.IntegerField(blank=True, null=True, verbose_name="AP Code"), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ( + "early_action_activities", + models.ManyToManyField( + blank=True, + related_name="planned_operations_early_action_activities", + to="eap.operationactivity", + verbose_name="Early Action Activities", + ), + ), + ( + "indicators", + models.ManyToManyField( + blank=True, + related_name="planned_operation_indicators", + to="eap.indicator", + verbose_name="Operation Indicators", + ), + ), + ( + "prepositioning_activities", + models.ManyToManyField( + blank=True, + related_name="planned_operations_prepositioning_activities", + to="eap.operationactivity", + verbose_name="Pre-positioning Activities", + ), + ), + ( + "readiness_activities", + models.ManyToManyField( + blank=True, + related_name="planned_operations_readiness_activities", + to="eap.operationactivity", + verbose_name="Readiness Activities", + ), + ), + ], + options={ + "verbose_name": "Planned Operation", + "verbose_name_plural": "Planned Operations", + }, + ), + migrations.CreateModel( + name="SourceInformation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "source_name", + models.CharField(max_length=255, verbose_name="Source Name"), + ), + ( + "source_link", + models.URLField(max_length=255, verbose_name="Source Link"), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ], + options={ + "verbose_name": "Source of Information", + "verbose_name_plural": "Source of Information", + }, + ), + migrations.CreateModel( + name="SimplifiedEAP", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "modified_at", + models.DateTimeField(auto_now=True, verbose_name="modified at"), + ), + ( + "people_targeted", + models.IntegerField(verbose_name="People Targeted."), + ), + ( + "national_society_contact_name", + models.CharField( + max_length=255, verbose_name="national society contact name" + ), + ), + ( + "national_society_contact_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="national society contact title", + ), + ), + ( + "national_society_contact_email", + models.CharField( + max_length=255, verbose_name="national society contact email" + ), + ), + ( + "national_society_contact_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="national society contact phone number", + ), + ), + ( + "ifrc_delegation_focal_point_name", + models.CharField( + max_length=255, verbose_name="IFRC delegation focal point name" + ), + ), + ( + "ifrc_delegation_focal_point_email", + models.CharField( + max_length=255, verbose_name="IFRC delegation focal point email" + ), + ), + ( + "ifrc_delegation_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point title", + ), + ), + ( + "ifrc_delegation_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC delegation focal point phone number", + ), + ), + ( + "ifrc_head_of_delegation_name", + models.CharField( + max_length=255, verbose_name="IFRC head of delegation name" + ), + ), + ( + "ifrc_head_of_delegation_email", + models.CharField( + max_length=255, verbose_name="IFRC head of delegation email" + ), + ), + ( + "ifrc_head_of_delegation_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation title", + ), + ), + ( + "ifrc_head_of_delegation_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC head of delegation phone number", + ), + ), + ( + "dref_focal_point_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="dref focal point name", + ), + ), + ( + "dref_focal_point_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Dref focal point email", + ), + ), + ( + "dref_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Dref focal point title", + ), + ), + ( + "dref_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Dref focal point phone number", + ), + ), + ( + "ifrc_regional_focal_point_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional focal point name", + ), + ), + ( + "ifrc_regional_focal_point_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional focal point email", + ), + ), + ( + "ifrc_regional_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional focal point title", + ), + ), + ( + "ifrc_regional_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC regional focal point phone number", + ), + ), + ( + "ifrc_regional_ops_manager_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional ops manager name", + ), + ), + ( + "ifrc_regional_ops_manager_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional ops manager email", + ), + ), + ( + "ifrc_regional_ops_manager_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional ops manager title", + ), + ), + ( + "ifrc_regional_ops_manager_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC regional ops manager phone number", + ), + ), + ( + "ifrc_regional_head_dcc_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional head of DCC name", + ), + ), + ( + "ifrc_regional_head_dcc_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional head of DCC email", + ), + ), + ( + "ifrc_regional_head_dcc_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional head of DCC title", + ), + ), + ( + "ifrc_regional_head_dcc_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC regional head of DCC phone number", + ), + ), + ( + "ifrc_global_ops_coordinator_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC global ops coordinator name", + ), + ), + ( + "ifrc_global_ops_coordinator_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC global ops coordinator email", + ), + ), + ( + "ifrc_global_ops_coordinator_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC global ops coordinator title", + ), + ), + ( + "ifrc_global_ops_coordinator_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC global ops coordinator phone number", + ), + ), + ( + "export_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/exports/", + verbose_name="EAP Export File", + ), + ), + ( + "diff_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="EAP Diff PDF file", + ), + ), + ( + "review_checklist_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="Review Checklist File", + ), + ), + ( + "total_budget", + models.IntegerField(verbose_name="Total Budget (CHF)"), + ), + ( + "readiness_budget", + models.IntegerField(verbose_name="Readiness Budget (CHF)"), + ), + ( + "pre_positioning_budget", + models.IntegerField(verbose_name="Pre-positioning Budget (CHF)"), + ), + ( + "early_action_budget", + models.IntegerField(verbose_name="Early Actions Budget (CHF)"), + ), + ( + "seap_timeframe", + models.IntegerField( + help_text="Timeframe of the EAP in years.", + verbose_name="Timeframe (Years) of the EAP", + ), + ), + ( + "prioritized_hazard_and_impact", + models.TextField( + verbose_name="Prioritized Hazard and its historical impact." + ), + ), + ( + "risks_selected_protocols", + models.TextField(verbose_name="Risk selected for the protocols."), + ), + ( + "selected_early_actions", + models.TextField(verbose_name="Selected Early Actions"), + ), + ( + "overall_objective_intervention", + models.TextField( + help_text="Provide an objective statement that describe the main of the intervention.", + verbose_name="Overall objective of the intervention", + ), + ), + ( + "potential_geographical_high_risk_areas", + models.TextField( + verbose_name="Potential geographical high-risk areas" + ), + ), + ( + "assisted_through_operation", + models.TextField(verbose_name="Assisted through the operation"), + ), + ( + "selection_criteria", + models.TextField( + blank=True, + help_text="Explain the selection criteria for who will be targeted", + null=True, + verbose_name="Selection Criteria.", + ), + ), + ( + "trigger_statement", + models.TextField( + blank=True, null=True, verbose_name="Trigger Statement" + ), + ), + ( + "seap_lead_timeframe_unit", + models.IntegerField( + choices=[ + (10, "Years"), + (20, "Months"), + (30, "Days"), + (40, "Hours"), + ], + verbose_name="sEAP Lead Timeframe Unit", + ), + ), + ("seap_lead_time", models.IntegerField(verbose_name="sEAP Lead Time")), + ( + "operational_timeframe_unit", + models.IntegerField( + choices=[ + (10, "Years"), + (20, "Months"), + (30, "Days"), + (40, "Hours"), + ], + default=20, + verbose_name="Operational Timeframe Unit", + ), + ), + ( + "operational_timeframe", + models.IntegerField(verbose_name="Operational Time"), + ), + ( + "trigger_threshold_justification", + models.TextField( + help_text="Explain how the trigger were set and provide information", + verbose_name="Trigger Threshold Justification", + ), + ), + ( + "next_step_towards_full_eap", + models.TextField(verbose_name="Next Steps towards Full EAP"), + ), + ( + "early_action_capability", + models.TextField( + help_text="Assumptions or minimum conditions needed to deliver the early actions.", + verbose_name="Experience or Capacity to implement Early Action.", + ), + ), + ( + "rcrc_movement_involvement", + models.TextField( + help_text="RCRC Movement partners, Governmental/other agencies consulted/involved.", + verbose_name="RCRC Movement Involvement.", + ), + ), + ( + "version", + models.IntegerField( + default=1, + help_text="Version identifier for the Simplified EAP.", + verbose_name="Version", + ), + ), + ( + "is_locked", + models.BooleanField( + default=False, + help_text="Indicates whether the Simplified EAP is locked for editing.", + verbose_name="Is Locked?", + ), + ), + ( + "admin2", + models.ManyToManyField( + blank=True, + related_name="+", + to="api.admin2", + verbose_name="admin", + ), + ), + ( + "budget_file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="eap.eapfile", + verbose_name="Budget File", + ), + ), + ( + "cover_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="eap.eapfile", + verbose_name="cover image", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + ( + "eap_registration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="simplified_eaps", + to="eap.eapregistration", + verbose_name="EAP Development Registration", + ), + ), + ( + "enabling_approaches", + models.ManyToManyField( + blank=True, + related_name="simplified_eap_enabling_approaches", + to="eap.enablingapproach", + verbose_name="Enabling Approaches", + ), + ), + ( + "hazard_impact_images", + models.ManyToManyField( + blank=True, + related_name="simplified_eap_hazard_impact_images", + to="eap.eapfile", + verbose_name="Hazard Impact Images", + ), + ), + ( + "modified_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified_by", + to=settings.AUTH_USER_MODEL, + verbose_name="modified by", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + help_text="Reference to the parent Simplified EAP if this is a snapshot.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="snapshots", + to="eap.simplifiedeap", + verbose_name="Parent Simplified EAP", + ), + ), + ( + "partner_contacts", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapcontact", + verbose_name="Partner NS Contacts", + ), + ), + ( + "planned_operations", + models.ManyToManyField( + blank=True, + to="eap.plannedoperation", + verbose_name="Planned Operations", + ), + ), + ( + "risk_selected_protocols_images", + models.ManyToManyField( + blank=True, + related_name="simplified_eap_risk_selected_protocols_images", + to="eap.eapfile", + verbose_name="Risk Selected Protocols Images", + ), + ), + ( + "selected_early_actions_images", + models.ManyToManyField( + blank=True, + related_name="simplified_eap_selected_early_actions_images", + to="eap.eapfile", + verbose_name="Selected Early Actions Images", + ), + ), + ( + "updated_checklist_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="eap.eapfile", + verbose_name="Updated Review Checklist File", + ), + ), + ], + options={ + "verbose_name": "Simplified EAP", + "verbose_name_plural": "Simplified EAPs", + "ordering": ["-id"], + }, + ), + migrations.CreateModel( + name="KeyActor", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "description", + models.TextField( + help_text="Describe this actor’s involvement.", + verbose_name="Description", + ), + ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + ( + "national_society", + models.ForeignKey( + help_text="Select the National Society involved in the EAP development.", + on_delete=django.db.models.deletion.CASCADE, + related_name="eap_key_actors", + to="api.country", + verbose_name="EAP Actors", + ), + ), + ], + options={ + "verbose_name": "Key Actor", + "verbose_name_plural": "Key Actor", + }, + ), + migrations.CreateModel( + name="FullEAP", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "modified_at", + models.DateTimeField(auto_now=True, verbose_name="modified at"), + ), + ( + "people_targeted", + models.IntegerField(verbose_name="People Targeted."), + ), + ( + "national_society_contact_name", + models.CharField( + max_length=255, verbose_name="national society contact name" + ), + ), + ( + "national_society_contact_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="national society contact title", + ), + ), + ( + "national_society_contact_email", + models.CharField( + max_length=255, verbose_name="national society contact email" + ), + ), + ( + "national_society_contact_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="national society contact phone number", + ), + ), + ( + "ifrc_delegation_focal_point_name", + models.CharField( + max_length=255, verbose_name="IFRC delegation focal point name" + ), + ), + ( + "ifrc_delegation_focal_point_email", + models.CharField( + max_length=255, verbose_name="IFRC delegation focal point email" + ), + ), + ( + "ifrc_delegation_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point title", + ), + ), + ( + "ifrc_delegation_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC delegation focal point phone number", + ), + ), + ( + "ifrc_head_of_delegation_name", + models.CharField( + max_length=255, verbose_name="IFRC head of delegation name" + ), + ), + ( + "ifrc_head_of_delegation_email", + models.CharField( + max_length=255, verbose_name="IFRC head of delegation email" + ), + ), + ( + "ifrc_head_of_delegation_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation title", + ), + ), + ( + "ifrc_head_of_delegation_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC head of delegation phone number", + ), + ), + ( + "dref_focal_point_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="dref focal point name", + ), + ), + ( + "dref_focal_point_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Dref focal point email", + ), + ), + ( + "dref_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Dref focal point title", + ), + ), + ( + "dref_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Dref focal point phone number", + ), + ), + ( + "ifrc_regional_focal_point_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional focal point name", + ), + ), + ( + "ifrc_regional_focal_point_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional focal point email", + ), + ), + ( + "ifrc_regional_focal_point_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional focal point title", + ), + ), + ( + "ifrc_regional_focal_point_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC regional focal point phone number", + ), + ), + ( + "ifrc_regional_ops_manager_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional ops manager name", + ), + ), + ( + "ifrc_regional_ops_manager_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional ops manager email", + ), + ), + ( + "ifrc_regional_ops_manager_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional ops manager title", + ), + ), + ( + "ifrc_regional_ops_manager_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC regional ops manager phone number", + ), + ), + ( + "ifrc_regional_head_dcc_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional head of DCC name", + ), + ), + ( + "ifrc_regional_head_dcc_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional head of DCC email", + ), + ), + ( + "ifrc_regional_head_dcc_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC regional head of DCC title", + ), + ), + ( + "ifrc_regional_head_dcc_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC regional head of DCC phone number", + ), + ), + ( + "ifrc_global_ops_coordinator_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC global ops coordinator name", + ), + ), + ( + "ifrc_global_ops_coordinator_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC global ops coordinator email", + ), + ), + ( + "ifrc_global_ops_coordinator_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC global ops coordinator title", + ), + ), + ( + "ifrc_global_ops_coordinator_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="IFRC global ops coordinator phone number", + ), + ), + ( + "export_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/exports/", + verbose_name="EAP Export File", + ), + ), + ( + "diff_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="EAP Diff PDF file", + ), + ), + ( + "review_checklist_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="Review Checklist File", + ), + ), + ( + "total_budget", + models.IntegerField(verbose_name="Total Budget (CHF)"), + ), + ( + "readiness_budget", + models.IntegerField(verbose_name="Readiness Budget (CHF)"), + ), + ( + "pre_positioning_budget", + models.IntegerField(verbose_name="Pre-positioning Budget (CHF)"), + ), + ( + "early_action_budget", + models.IntegerField(verbose_name="Early Actions Budget (CHF)"), + ), + ( + "expected_submission_time", + models.DateField( + help_text="Include the propose time of submission, accounting for the time it will take to deliver the application.", + verbose_name="Expected submission time", + ), + ), + ( + "objective", + models.TextField( + help_text="Provide an objective statement that describe the main goal of intervention.", + verbose_name="Overall Objective of the EAP.", + ), + ), + ( + "is_worked_with_government", + models.BooleanField( + default=False, + verbose_name="Has Worked with government or other relevant actors.", + ), + ), + ( + "worked_with_government_description", + models.TextField( + blank=True, + null=True, + verbose_name="Government and actors engagement description", + ), + ), + ( + "is_technical_working_groups", + models.BooleanField( + blank=True, + null=True, + verbose_name="Are technical working groups in place", + ), + ), + ( + "technically_working_group_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Technical working group title", + ), + ), + ( + "technical_working_groups_in_place_description", + models.TextField( + blank=True, + null=True, + verbose_name="Technical working groups description", + ), + ), + ( + "hazard_selection", + models.TextField( + help_text="Provide a brief rationale for selecting this hazard for the FbF system.", + verbose_name="Hazard selection", + ), + ), + ( + "exposed_element_and_vulnerability_factor", + models.TextField( + help_text="Explain which people are most likely to experience the impacts of this hazard.", + verbose_name="Exposed elements and vulnerability factors", + ), + ), + ( + "prioritized_impact", + models.TextField( + help_text="Describe the impacts that have been prioritized and who is most likely to be affected.", + verbose_name="Prioritized impact", + ), + ), + ( + "trigger_statement", + models.TextField( + help_text="Explain in one sentence what exactly the trigger of your EAP will be.", + verbose_name="Trigger Statement", + ), + ), + ("lead_time", models.IntegerField(verbose_name="Lead Time")), + ( + "forecast_selection", + models.TextField( + help_text="Explain which forecast's and observations will be used and why they are chosen", + verbose_name="Forecast Selection", + ), + ), + ( + "definition_and_justification_impact_level", + models.TextField( + verbose_name="Definition and Justification of Impact Level" + ), + ), + ( + "identification_of_the_intervention_area", + models.TextField( + verbose_name="Identification of Intervention Area" + ), + ), + ( + "early_action_selection_process", + models.TextField(verbose_name="Early action selection process"), + ), + ( + "evidence_base", + models.TextField( + help_text="Explain how the selected actions will reduce the expected disaster impacts.", + verbose_name="Evidence base", + ), + ), + ( + "usefulness_of_actions", + models.TextField( + help_text="Describe how actions will still benefit the population if the expected event does not occur.", + verbose_name="Usefulness of actions in case the event does not occur", + ), + ), + ( + "feasibility", + models.TextField( + help_text="Explain how feasible it is to implement the proposed early actions in the planned timeframe.", + verbose_name="Feasibility of selected actions", + ), + ), + ( + "early_action_implementation_process", + models.TextField( + help_text="Describe the process for implementing early actions.", + verbose_name="Early Action Implementation Process", + ), + ), + ( + "trigger_activation_system", + models.TextField( + help_text="Describe the automatic system used to monitor the forecasts.", + verbose_name="Trigger Activation System", + ), + ), + ( + "selection_of_target_population", + models.TextField( + help_text="Describe the process used to select the target population for early actions.", + verbose_name="Selection of Target Population", + ), + ), + ( + "stop_mechanism", + models.TextField( + help_text="Explain how it would be communicated to communities and stakeholders that the activities are being stopped.", + verbose_name="Stop Mechanism", + ), + ), + ("meal", models.TextField(verbose_name="MEAL Plan Description")), + ( + "operational_administrative_capacity", + models.TextField( + help_text="Describe how the NS has operative and administrative capacity to implement the EAPs.", + verbose_name="National Society Operational, thematic and administrative capacity", + ), + ), + ( + "strategies_and_plans", + models.TextField( + help_text="Describe how the EAP aligned with disaster risk management strategy of NS.", + verbose_name="National Society Strategies and plans", + ), + ), + ( + "advance_financial_capacity", + models.TextField( + help_text="Indicate whether the NS has capacity to advance funds to start early actions.", + verbose_name="National Society Financial capacity to advance funds", + ), + ), + ( + "budget_description", + models.TextField(verbose_name="Full EAP Budget Description"), + ), + ( + "readiness_cost_description", + models.TextField(verbose_name="Readiness Cost Description"), + ), + ( + "prepositioning_cost_description", + models.TextField(verbose_name="Prepositioning Cost Description"), + ), + ( + "early_action_cost_description", + models.TextField(verbose_name="Early Action Cost Description"), + ), + ( + "eap_endorsement", + models.TextField( + help_text="Describe by whom,how and when the EAP was agreed and endorsed.", + verbose_name="EAP Endorsement Description", + ), + ), + ( + "version", + models.IntegerField( + default=1, + help_text="Version identifier for the Full EAP.", + verbose_name="Version", + ), + ), + ( + "is_locked", + models.BooleanField( + default=False, + help_text="Indicates whether the Full EAP is locked for editing.", + verbose_name="Is Locked?", + ), + ), + ( + "activation_process_relevant_files", + models.ManyToManyField( + blank=True, + related_name="activation_process_relevant_files", + to="eap.eapfile", + verbose_name="Activation Relevant Files", + ), + ), + ( + "activation_process_source_of_information", + models.ManyToManyField( + blank=True, + related_name="activation_process_source_of_information", + to="eap.sourceinformation", + verbose_name="Activation Process Source of Information", + ), + ), + ( + "admin2", + models.ManyToManyField( + blank=True, + related_name="+", + to="api.admin2", + verbose_name="admin", + ), + ), + ( + "budget_file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="eap.eapfile", + verbose_name="Budget File", + ), + ), + ( + "capacity_relevant_files", + models.ManyToManyField( + blank=True, + related_name="ns_capacity_relevant_files", + to="eap.eapfile", + verbose_name="National society capacity relevant files", + ), + ), + ( + "cover_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="eap.eapfile", + verbose_name="cover image", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + ( + "definition_and_justification_impact_level_images", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Definition and Justification Impact Level Images", + ), + ), + ( + "eap_registration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="full_eaps", + to="eap.eapregistration", + verbose_name="EAP Development Registration", + ), + ), + ( + "early_action_implementation_images", + models.ManyToManyField( + blank=True, + related_name="early_action_implementation_images", + to="eap.eapfile", + verbose_name="Early Action Implementation Images", + ), + ), + ( + "early_action_selection_process_images", + models.ManyToManyField( + blank=True, + related_name="early_action_selection_process_images", + to="eap.eapfile", + verbose_name="Early action selection process images", + ), + ), + ( + "early_actions", + models.ManyToManyField( + related_name="full_eap_early_actions", + to="eap.eapaction", + verbose_name="Early Actions", + ), + ), + ( + "enabling_approaches", + models.ManyToManyField( + blank=True, + related_name="full_eap_enabling_approaches", + to="eap.enablingapproach", + verbose_name="Enabling approaches", + ), + ), + ( + "evidence_base_relevant_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_evidence_base_relavent_files", + to="eap.eapfile", + verbose_name="Evidence base files", + ), + ), + ( + "evidence_base_source_of_information", + models.ManyToManyField( + blank=True, + related_name="evidence_base_source_of_information", + to="eap.sourceinformation", + verbose_name="Evidence base source of information", + ), + ), + ( + "exposed_element_and_vulnerability_factor_images", + models.ManyToManyField( + blank=True, + related_name="full_eap_vulnerability_factor_images", + to="eap.eapfile", + verbose_name="Exposed elements and vulnerability factors images", + ), + ), + ( + "forecast_selection_images", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Forecast Selection Images", + ), + ), + ( + "forecast_table_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="forecast_table_file", + to="eap.eapfile", + verbose_name="Forecast Table File", + ), + ), + ( + "hazard_selection_images", + models.ManyToManyField( + blank=True, + related_name="full_eap_hazard_selection_images", + to="eap.eapfile", + verbose_name="Hazard images", + ), + ), + ( + "identification_of_the_intervention_area_images", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Intervention Area Images", + ), + ), + ( + "key_actors", + models.ManyToManyField( + related_name="full_eap_key_actor", + to="eap.keyactor", + verbose_name="Key Actors", + ), + ), + ( + "meal_relevant_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_meal_files", + to="eap.eapfile", + verbose_name="Meal files", + ), + ), + ( + "modified_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified_by", + to=settings.AUTH_USER_MODEL, + verbose_name="modified by", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + help_text="Reference to the parent Full EAP if this is a snapshot.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="snapshots", + to="eap.fulleap", + verbose_name="Parent FUll EAP", + ), + ), + ( + "partner_contacts", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapcontact", + verbose_name="Partner NS Contacts", + ), + ), + ( + "planned_operations", + models.ManyToManyField( + blank=True, + related_name="full_eap_planned_operation", + to="eap.plannedoperation", + verbose_name="Planned operations", + ), + ), + ( + "prioritized_impact_images", + models.ManyToManyField( + blank=True, + related_name="full_eap_prioritized_impact_images", + to="eap.eapfile", + verbose_name="Prioritized impact images", + ), + ), + ( + "prioritized_impacts", + models.ManyToManyField( + related_name="full_eap_prioritized_impacts", + to="eap.eapimpact", + verbose_name="Prioritized impacts", + ), + ), + ( + "risk_analysis_relevant_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_risk_analysis_relevant_files", + to="eap.eapfile", + verbose_name="Risk analysis relevant files", + ), + ), + ( + "risk_analysis_source_of_information", + models.ManyToManyField( + blank=True, + related_name="risk_analysis_source_of_information", + to="eap.sourceinformation", + verbose_name="Risk analysis source of information", + ), + ), + ( + "theory_of_change_table_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="theory_of_change_table_file", + to="eap.eapfile", + verbose_name="Theory of Change Table File", + ), + ), + ( + "trigger_activation_system_images", + models.ManyToManyField( + blank=True, + related_name="trigger_activation_system_images", + to="eap.eapfile", + verbose_name="Trigger Activation System Images", + ), + ), + ( + "trigger_model_relevant_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_trigger_model_relevant_file", + to="eap.eapfile", + verbose_name="Trigger Model Relevant File", + ), + ), + ( + "trigger_model_source_of_information", + models.ManyToManyField( + blank=True, + related_name="trigger_model_source_of_information", + to="eap.sourceinformation", + verbose_name="Target Model Source of Information", + ), + ), + ( + "trigger_statement_source_of_information", + models.ManyToManyField( + blank=True, + related_name="trigger_statement_source_of_information", + to="eap.sourceinformation", + verbose_name="Trigger Statement Source of Forecast", + ), + ), + ( + "updated_checklist_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="eap.eapfile", + verbose_name="Updated Review Checklist File", + ), + ), + ], + options={ + "verbose_name": "Full EAP", + "verbose_name_plural": "Full EAPs", + "ordering": ["-id"], + }, + ), + migrations.AddField( + model_name="enablingapproach", + name="early_action_activities", + field=models.ManyToManyField( + blank=True, + related_name="enabling_approach_early_action_activities", + to="eap.operationactivity", + verbose_name="Early Action Activities", + ), + ), + migrations.AddField( + model_name="enablingapproach", + name="indicators", + field=models.ManyToManyField( + blank=True, + related_name="enabling_approach_indicators", + to="eap.indicator", + verbose_name="Enabling Approach Indicators", + ), + ), + migrations.AddField( + model_name="enablingapproach", + name="prepositioning_activities", + field=models.ManyToManyField( + blank=True, + related_name="enabling_approach_prepositioning_activities", + to="eap.operationactivity", + verbose_name="Pre-positioning Activities", + ), + ), + migrations.AddField( + model_name="enablingapproach", + name="readiness_activities", + field=models.ManyToManyField( + blank=True, + related_name="enabling_approach_readiness_activities", + to="eap.operationactivity", + verbose_name="Readiness Activities", + ), + ), + migrations.AddField( + model_name="eapregistration", + name="latest_full_eap", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="eap.fulleap", + verbose_name="Latest Full EAP", + ), + ), + migrations.AddField( + model_name="eapregistration", + name="latest_simplified_eap", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="eap.simplifiedeap", + verbose_name="Latest Simplified EAP", + ), + ), + migrations.AddField( + model_name="eapregistration", + name="modified_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_modified_by", + to=settings.AUTH_USER_MODEL, + verbose_name="modified by", + ), + ), + migrations.AddField( + model_name="eapregistration", + name="national_society", + field=models.ForeignKey( + help_text="Select National Society that is planning to apply for the EAP", + on_delete=django.db.models.deletion.CASCADE, + related_name="development_registration_eap_national_society", + to="api.country", + verbose_name="National Society (NS)", + ), + ), + migrations.AddField( + model_name="eapregistration", + name="partners", + field=models.ManyToManyField( + blank=True, + help_text="Select any partner NS involved in the EAP development.", + related_name="development_registration_eap_partners", + to="api.country", + verbose_name="Partners", + ), + ), + migrations.AddConstraint( + model_name="simplifiedeap", + constraint=models.UniqueConstraint( + fields=("eap_registration", "version"), + name="unique_simplified_eap_version", + ), + ), + migrations.AddConstraint( + model_name="fulleap", + constraint=models.UniqueConstraint( + fields=("eap_registration", "version"), name="unique_full_eap_version" + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index dce1df67b..4656466cf 100644 --- a/eap/models.py +++ b/eap/models.py @@ -1,8 +1,10 @@ from django.conf import settings -from django.db import models +from django.contrib.postgres.fields import ArrayField +from django.db import models, transaction from django.utils.translation import gettext_lazy as _ -from api.models import Country, DisasterType, District +from api.models import Admin2, Country, DisasterType, District +from main.fields import SecureFileField class EarlyActionIndicator(models.Model): @@ -174,3 +176,1452 @@ class Meta: def __str__(self): return f"{self.id}" + + +# --- Early Action Protocol --- ## + + +class EAPBaseModel(models.Model): + """Base model for EAP models to include common fields.""" + + created_at = models.DateTimeField( + verbose_name=_("created at"), + auto_now_add=True, + ) + modified_at = models.DateTimeField( + verbose_name=_("modified at"), + auto_now=True, + ) + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("created by"), + on_delete=models.PROTECT, + related_name="%(class)s_created_by", + ) + modified_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("modified by"), + on_delete=models.PROTECT, + related_name="%(class)s_modified_by", + ) + + # TYPING + id: int + created_by_id: int + modified_by_id: int + + class Meta: + abstract = True + ordering = ["-id"] + + +class EAPFile(EAPBaseModel): + file = SecureFileField( + verbose_name=_("file"), + upload_to="eap/files/", + null=False, + blank=False, + help_text=_("Upload EAP related file."), + ) + caption = models.CharField(max_length=225, blank=True, null=True) + + class Meta: + verbose_name = _("eap file") + verbose_name_plural = _("eap files") + ordering = ["-id"] + + +class EAPContact(models.Model): + name = models.CharField(max_length=255, verbose_name=_("Contact Name")) + email = models.EmailField(max_length=255, verbose_name=_("Contact Email")) + title = models.CharField(max_length=255, verbose_name=_("Contact Title"), null=True, blank=True) + phone_number = models.CharField(max_length=100, verbose_name=_("Contact Phone Number"), null=True, blank=True) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + class Meta: + verbose_name = _("EAP Contact") + verbose_name_plural = _("EAP Contacts") + + def __str__(self): + return f"{self.name}" + + +class TimeFrame(models.IntegerChoices): + YEARS = 10, _("Years") + MONTHS = 20, _("Months") + DAYS = 30, _("Days") + HOURS = 40, _("Hours") + + +class YearsTimeFrameChoices(models.IntegerChoices): + ONE_YEAR = 1, _("1") + TWO_YEARS = 2, _("2") + THREE_YEARS = 3, _("3") + FOUR_YEARS = 4, _("4") + FIVE_YEARS = 5, _("5") + + +class MonthsTimeFrameChoices(models.IntegerChoices): + ONE_MONTH = 1, _("1") + TWO_MONTHS = 2, _("2") + THREE_MONTHS = 3, _("3") + FOUR_MONTHS = 4, _("4") + FIVE_MONTHS = 5, _("5") + SIX_MONTHS = 6, _("6") + SEVEN_MONTHS = 7, _("7") + EIGHT_MONTHS = 8, _("8") + NINE_MONTHS = 9, _("9") + TEN_MONTHS = 10, _("10") + ELEVEN_MONTHS = 11, _("11") + TWELVE_MONTHS = 12, _("12") + + +class DaysTimeFrameChoices(models.IntegerChoices): + ONE_DAY = 1, _("1") + TWO_DAYS = 2, _("2") + THREE_DAYS = 3, _("3") + FOUR_DAYS = 4, _("4") + FIVE_DAYS = 5, _("5") + SIX_DAYS = 6, _("6") + SEVEN_DAYS = 7, _("7") + EIGHT_DAYS = 8, _("8") + NINE_DAYS = 9, _("9") + TEN_DAYS = 10, _("10") + ELEVEN_DAYS = 11, _("11") + TWELVE_DAYS = 12, _("12") + THIRTEEN_DAYS = 13, _("13") + FOURTEEN_DAYS = 14, _("14") + FIFTEEN_DAYS = 15, _("15") + SIXTEEN_DAYS = 16, _("16") + SEVENTEEN_DAYS = 17, _("17") + EIGHTEEN_DAYS = 18, _("18") + NINETEEN_DAYS = 19, _("19") + TWENTY_DAYS = 20, _("20") + TWENTY_ONE_DAYS = 21, _("21") + TWENTY_TWO_DAYS = 22, _("22") + TWENTY_THREE_DAYS = 23, _("23") + TWENTY_FOUR_DAYS = 24, _("24") + TWENTY_FIVE_DAYS = 25, _("25") + TWENTY_SIX_DAYS = 26, _("26") + TWENTY_SEVEN_DAYS = 27, _("27") + TWENTY_EIGHT_DAYS = 28, _("28") + TWENTY_NINE_DAYS = 29, _("29") + THIRTY_DAYS = 30, _("30") + THIRTY_ONE_DAYS = 31, _("31") + + +class HoursTimeFrameChoices(models.IntegerChoices): + ZERO_TO_FIVE_HOURS = 5, _("0-5") + FIVE_TO_TEN_HOURS = 10, _("5-10") + TEN_TO_FIFTEEN_HOURS = 15, _("10-15") + FIFTEEN_TO_TWENTY_HOURS = 20, _("15-20") + TWENTY_TO_TWENTY_FIVE_HOURS = 25, _("20-25") + TWENTY_FIVE_TO_THIRTY_HOURS = 30, _("25-30") + + +class OperationActivity(models.Model): + # NOTE: `timeframe` and `time_value` together represent the time span for an activity. + # Make sure to keep them in sync. + activity = models.CharField(max_length=255, verbose_name=_("Activity")) + timeframe = models.IntegerField(choices=TimeFrame.choices, verbose_name=_("Timeframe")) + time_value = ArrayField( + base_field=models.IntegerField(), + verbose_name=_("Activity time span"), + ) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + class Meta: + verbose_name = _("Operation Activity") + verbose_name_plural = _("Operation Activities") + + def __str__(self): + return f"{self.activity}" + + +class Indicator(models.Model): + title = models.CharField(max_length=255, verbose_name=_("Indicator Title")) + target = models.IntegerField(verbose_name=_("Indicator Target")) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + class Meta: + verbose_name = _("Indicator") + verbose_name_plural = _("Indicators") + + def __str__(self): + return self.title + + +class EAPAction(models.Model): + action = models.CharField(max_length=255, verbose_name=_("Early Action")) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + class Meta: + verbose_name = _("Early Action") + verbose_name_plural = _("Early Actions") + + def __str__(self): + return f"{self.action}" + + +class EAPImpact(models.Model): + impact = models.CharField(max_length=255, verbose_name=_("Impact")) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + class Meta: + verbose_name = _(" Impact") + verbose_name_plural = _("Expected Impacts") + + def __str__(self): + return f"{self.impact}" + + +class PlannedOperation(models.Model): + class Sector(models.IntegerChoices): + SHELTER = 101, _("Shelter") + SETTLEMENT_AND_HOUSING = 102, _("Settlement and Housing") + LIVELIHOODS = 103, _("Livelihoods") + PROTECTION_GENDER_AND_INCLUSION = 104, _("Protection, Gender and Inclusion") + HEALTH_AND_CARE = 105, _("Health and Care") + RISK_REDUCTION = 106, _("Risk Reduction") + CLIMATE_ADAPTATION_AND_RECOVERY = 107, _("Climate Adaptation and Recovery") + MULTIPURPOSE_CASH = 108, _("Multipurpose Cash") + WATER_SANITATION_AND_HYGIENE = 109, _("Water, Sanitation And Hygiene") + WASH = 110, _("WASH") + EDUCATION = 111, _("Education") + MIGRATION = 112, _("Migration") + ENVIRONMENT_SUSTAINABILITY = 113, _("Environment Sustainability") + COMMUNITY_ENGAGEMENT_AND_ACCOUNTABILITY = 114, _("Community Engagement And Accountability") + + sector = models.IntegerField(choices=Sector.choices, verbose_name=_("sector")) + people_targeted = models.IntegerField(verbose_name=_("People Targeted")) + budget_per_sector = models.IntegerField(verbose_name=_("Budget per sector (CHF)")) + ap_code = models.IntegerField(verbose_name=_("AP Code"), null=True, blank=True) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + indicators = models.ManyToManyField( + Indicator, + verbose_name=_("Operation Indicators"), + blank=True, + related_name="planned_operation_indicators", + ) + + # Activities + readiness_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Readiness Activities"), + related_name="planned_operations_readiness_activities", + blank=True, + ) + prepositioning_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Pre-positioning Activities"), + related_name="planned_operations_prepositioning_activities", + blank=True, + ) + early_action_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Early Action Activities"), + related_name="planned_operations_early_action_activities", + blank=True, + ) + + class Meta: + verbose_name = _("Planned Operation") + verbose_name_plural = _("Planned Operations") + + def __str__(self): + return f"Planned Operation - {self.get_sector_display()}" + + +class EnablingApproach(models.Model): + class Approach(models.IntegerChoices): + SECRETARIAT_SERVICES = 10, _("Secretariat Services") + NATIONAL_SOCIETY_STRENGTHENING = 20, _("National Society Strengthening") + PARTNERSHIP_AND_COORDINATION = 30, _("Partnership And Coordination") + + approach = models.IntegerField(choices=Approach.choices, verbose_name=_("Approach")) + budget_per_approach = models.IntegerField(verbose_name=_("Budget per approach (CHF)")) + ap_code = models.IntegerField(verbose_name=_("AP Code"), null=True, blank=True) + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + indicators = models.ManyToManyField( + Indicator, + verbose_name=_("Enabling Approach Indicators"), + blank=True, + related_name="enabling_approach_indicators", + ) + + # Activities + readiness_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Readiness Activities"), + related_name="enabling_approach_readiness_activities", + blank=True, + ) + prepositioning_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Pre-positioning Activities"), + related_name="enabling_approach_prepositioning_activities", + blank=True, + ) + early_action_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Early Action Activities"), + related_name="enabling_approach_early_action_activities", + blank=True, + ) + + class Meta: + verbose_name = _("Enabling Approach") + verbose_name_plural = _("Enabling Approaches") + + def __str__(self): + return f"Enabling Approach - {self.get_approach_display()}" + + +class SourceInformation(models.Model): + source_name = models.CharField( + verbose_name=_("Source Name"), + max_length=255, + ) + source_link = models.URLField( + verbose_name=_("Source Link"), + max_length=255, + ) + previous_id = models.PositiveIntegerField( + verbose_name=_("Previous ID"), + null=True, + blank=True, + ) + + class Meta: + verbose_name = _("Source of Information") + verbose_name_plural = _("Source of Information") + + def __str__(self): + return self.source_name + + +class KeyActor(models.Model): + national_society = models.ForeignKey( + Country, + on_delete=models.CASCADE, + verbose_name=_("EAP Actors"), + help_text=_("Select the National Society involved in the EAP development."), + related_name="eap_key_actors", + ) + + description = models.TextField( + verbose_name=_("Description"), + help_text=_("Describe this actor’s involvement."), + ) + + previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) + + class Meta: + verbose_name = _("Key Actor") + verbose_name_plural = _("Key Actor") + + +class EAPType(models.IntegerChoices): + """Enum representing the type of EAP.""" + + FULL_EAP = 10, _("Full EAP") + """Full EAP Application """ + + SIMPLIFIED_EAP = 20, _("Simplified EAP") + """Simplified EAP Application """ + + +class EAPStatus(models.IntegerChoices): + """Enum representing the status of a EAP.""" + + UNDER_DEVELOPMENT = 10, _("Under Development") + """Initial status when an EAP is being created.""" + + UNDER_REVIEW = 20, _("Under Review") + """EAP has been submitted by NS. It is under review by IFRC and/or technical partners.""" + + NS_ADDRESSING_COMMENTS = 30, _("NS Addressing Comments") + """NS is addressing comments provided during the review process. + IFRC has to upload review checklist. + EAP can be changed to UNDER_REVIEW once comments have been addressed. + """ + + TECHNICALLY_VALIDATED = 40, _("Technically Validated") + """EAP has been technically validated by IFRC and/or technical partners. + IFRC can change status to NS_ADDRESSING_COMMENTS or PENDING_PFA. + """ + + PENDING_PFA = 50, _("Pending PFA") + """EAP is in the process of signing the PFA between IFRC and NS. + """ + + APPROVED = 60, _("Approved") + """IFRC has to upload validated budget file. + Cannot be changed back to previous statuses. + """ + + ACTIVATED = 70, _("Activated") + """EAP has been activated""" + + +# BASE MODEL FOR EAP +class EAPRegistration(EAPBaseModel): + """Model representing the EAP Development Registration.""" + + Status = EAPStatus + + # National Society + national_society = models.ForeignKey( + Country, + on_delete=models.CASCADE, + verbose_name=_("National Society (NS)"), + help_text=_("Select National Society that is planning to apply for the EAP"), + related_name="development_registration_eap_national_society", + ) + country = models.ForeignKey( + Country, + on_delete=models.CASCADE, + verbose_name=_("Country"), + help_text=_("The country will be pre-populated based on the NS selection, but can be adapted as needed."), + related_name="development_registration_eap_country", + ) + + # Disaster + disaster_type = models.ForeignKey[DisasterType, DisasterType]( + DisasterType, + verbose_name=("Disaster Type"), + on_delete=models.PROTECT, + help_text=_("Select the disaster type for which the EAP is needed"), + ) + eap_type = models.IntegerField( + choices=EAPType.choices, + verbose_name=_("EAP Type"), + help_text=_("Select the type of EAP."), + null=True, + blank=True, + ) + status = models.IntegerField( + choices=EAPStatus.choices, + verbose_name=_("EAP Status"), + default=EAPStatus.UNDER_DEVELOPMENT, + help_text=_("Select the current status of the EAP development process."), + ) + + expected_submission_time = models.DateField( + verbose_name=_("Expected submission time"), + help_text=_( + "Include the propose time of submission, accounting for the time it will take to deliver the application." + "Leave blank if not sure." + ), + blank=True, + null=True, + ) + + partners = models.ManyToManyField( + Country, + verbose_name=_("Partners"), + help_text=_("Select any partner NS involved in the EAP development."), + related_name="development_registration_eap_partners", + blank=True, + ) + + # Validated Budget file + validated_budget_file = SecureFileField( + upload_to="eap/files/validated_budgets/", + blank=True, + null=True, + verbose_name=_("Validated Budget File"), + help_text=_("Upload the validated budget file once the EAP is technically validated."), + ) + + # NOTE: Only Full EAP have summary PDF + summary_file = SecureFileField( + verbose_name=_("EAP Summary PDF"), + upload_to="eap/files/", + null=True, + blank=True, + ) + + # Latest EAPs + latest_simplified_eap = models.ForeignKey( + "SimplifiedEAP", + on_delete=models.SET_NULL, + verbose_name=_("Latest Simplified EAP"), + related_name="+", + null=True, + blank=True, + ) + latest_full_eap = models.ForeignKey( + "FullEAP", + on_delete=models.SET_NULL, + verbose_name=_("Latest Full EAP"), + related_name="+", + null=True, + blank=True, + ) + + # Contacts + # National Society + national_society_contact_name = models.CharField( + verbose_name=_("national society contact name"), + max_length=255, + ) + national_society_contact_title = models.CharField( + verbose_name=_("national society contact title"), max_length=255, null=True, blank=True + ) + national_society_contact_email = models.CharField( + verbose_name=_("national society contact email"), + max_length=255, + ) + national_society_contact_phone_number = models.CharField( + verbose_name=_("national society contact phone number"), max_length=100, null=True, blank=True + ) + + # IFRC Contact + ifrc_contact_name = models.CharField(verbose_name=_("IFRC contact name "), max_length=255, null=True, blank=True) + ifrc_contact_email = models.CharField(verbose_name=_("IFRC contact email"), max_length=255, null=True, blank=True) + ifrc_contact_title = models.CharField(verbose_name=_("IFRC contact title"), max_length=255, null=True, blank=True) + ifrc_contact_phone_number = models.CharField( + verbose_name=_("IFRC contact phone number"), max_length=100, null=True, blank=True + ) + + # DREF Focal Point + dref_focal_point_name = models.CharField(verbose_name=_("dref focal point name"), max_length=255, null=True, blank=True) + dref_focal_point_email = models.CharField(verbose_name=_("Dref focal point email"), max_length=255, null=True, blank=True) + dref_focal_point_title = models.CharField(verbose_name=_("Dref focal point title"), max_length=255, null=True, blank=True) + dref_focal_point_phone_number = models.CharField( + verbose_name=_("Dref focal point phone number"), max_length=100, null=True, blank=True + ) + + # STATUS timestamps + technically_validated_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("technically validated at"), + help_text=_("Timestamp when the EAP was technically validated."), + ) + approved_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("approved at"), + help_text=_("Timestamp when the EAP was approved."), + ) + pending_pfa_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("pending pfa at"), + help_text=_("Timestamp when the EAP was marked as pending PFA."), + ) + activated_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("activated at"), + 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 + country_id: int + disaster_type_id: int + simplified_eaps: models.Manager["SimplifiedEAP"] + full_eaps: models.Manager["FullEAP"] + + class Meta: + verbose_name = _("Development Registration EAP") + verbose_name_plural = _("Development Registration EAPs") + ordering = ["-id"] + + def __str__(self): + # NOTE: Use select_related in admin get_queryset for national_society field to avoid extra queries + return f"EAP Development Registration - {self.national_society} - {self.disaster_type} - {self.get_eap_type_display()}" + + @property + def has_eap_application(self) -> bool: + """Check if the EAP Registration has an associated EAP application.""" + return self.simplified_eaps.exists() or self.full_eaps.exists() + + @property + def get_status_enum(self) -> EAPStatus: + """Get the status as an EAPStatus enum.""" + return EAPStatus(self.status) + + @property + def get_eap_type_enum(self) -> EAPType | None: + """Get the EAP type as an EAPType enum.""" + if self.eap_type is not None: + return EAPType(self.eap_type) + return None + + def update_status(self, status: EAPStatus, commit: bool = True): + self.status = status + if commit: + self.save(update_fields=("status",)) + + def update_eap_type(self, eap_type: EAPType, commit: bool = True): + self.eap_type = eap_type + if commit: + self.save(update_fields=("eap_type",)) + + +class CommonEAPFields(models.Model): + """Common fields for EAP models.""" + + cover_image = models.ForeignKey[EAPFile | None, EAPFile | None]( + EAPFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("cover image"), + related_name="+", + ) + + admin2 = models.ManyToManyField( + Admin2, + verbose_name=_("admin"), + blank=True, + related_name="+", + ) + + people_targeted = models.IntegerField( + verbose_name=_("People Targeted."), + ) + + # Contacts + # National Society + national_society_contact_name = models.CharField( + verbose_name=_("national society contact name"), + max_length=255, + ) + national_society_contact_title = models.CharField( + verbose_name=_("national society contact title"), max_length=255, null=True, blank=True + ) + national_society_contact_email = models.CharField( + verbose_name=_("national society contact email"), + max_length=255, + ) + national_society_contact_phone_number = models.CharField( + verbose_name=_("national society contact phone number"), max_length=100, null=True, blank=True + ) + + # Partners NS + partner_contacts = models.ManyToManyField( + EAPContact, + verbose_name=_("Partner NS Contacts"), + related_name="+", + blank=True, + ) + + # Delegations + ifrc_delegation_focal_point_name = models.CharField(verbose_name=_("IFRC delegation focal point name"), max_length=255) + ifrc_delegation_focal_point_email = models.CharField(verbose_name=_("IFRC delegation focal point email"), max_length=255) + ifrc_delegation_focal_point_title = models.CharField( + verbose_name=_("IFRC delegation focal point title"), max_length=255, null=True, blank=True + ) + ifrc_delegation_focal_point_phone_number = models.CharField( + verbose_name=_("IFRC delegation focal point phone number"), max_length=100, null=True, blank=True + ) + + ifrc_head_of_delegation_name = models.CharField(verbose_name=_("IFRC head of delegation name"), max_length=255) + ifrc_head_of_delegation_email = models.CharField(verbose_name=_("IFRC head of delegation email"), max_length=255) + ifrc_head_of_delegation_title = models.CharField( + verbose_name=_("IFRC head of delegation title"), max_length=255, null=True, blank=True + ) + ifrc_head_of_delegation_phone_number = models.CharField( + verbose_name=_("IFRC head of delegation phone number"), max_length=100, null=True, blank=True + ) + + # Regional and Global + # DREF Focal Point + dref_focal_point_name = models.CharField(verbose_name=_("dref focal point name"), max_length=255, null=True, blank=True) + dref_focal_point_email = models.CharField(verbose_name=_("Dref focal point email"), max_length=255, null=True, blank=True) + dref_focal_point_title = models.CharField(verbose_name=_("Dref focal point title"), max_length=255, null=True, blank=True) + dref_focal_point_phone_number = models.CharField( + verbose_name=_("Dref focal point phone number"), max_length=100, null=True, blank=True + ) + + # Regional + ifrc_regional_focal_point_name = models.CharField( + verbose_name=_("IFRC regional focal point name"), max_length=255, null=True, blank=True + ) + ifrc_regional_focal_point_email = models.CharField( + verbose_name=_("IFRC regional focal point email"), max_length=255, null=True, blank=True + ) + ifrc_regional_focal_point_title = models.CharField( + verbose_name=_("IFRC regional focal point title"), max_length=255, null=True, blank=True + ) + ifrc_regional_focal_point_phone_number = models.CharField( + verbose_name=_("IFRC regional focal point phone number"), max_length=100, null=True, blank=True + ) + + # Regional Ops Manager + ifrc_regional_ops_manager_name = models.CharField( + verbose_name=_("IFRC regional ops manager name"), max_length=255, null=True, blank=True + ) + ifrc_regional_ops_manager_email = models.CharField( + verbose_name=_("IFRC regional ops manager email"), max_length=255, null=True, blank=True + ) + ifrc_regional_ops_manager_title = models.CharField( + verbose_name=_("IFRC regional ops manager title"), max_length=255, null=True, blank=True + ) + ifrc_regional_ops_manager_phone_number = models.CharField( + verbose_name=_("IFRC regional ops manager phone number"), max_length=100, null=True, blank=True + ) + + # Regional Head DCC + ifrc_regional_head_dcc_name = models.CharField( + verbose_name=_("IFRC regional head of DCC name"), max_length=255, null=True, blank=True + ) + ifrc_regional_head_dcc_email = models.CharField( + verbose_name=_("IFRC regional head of DCC email"), max_length=255, null=True, blank=True + ) + ifrc_regional_head_dcc_title = models.CharField( + verbose_name=_("IFRC regional head of DCC title"), max_length=255, null=True, blank=True + ) + ifrc_regional_head_dcc_phone_number = models.CharField( + verbose_name=_("IFRC regional head of DCC phone number"), max_length=100, null=True, blank=True + ) + + # Global Ops Manager + ifrc_global_ops_coordinator_name = models.CharField( + verbose_name=_("IFRC global ops coordinator name"), max_length=255, null=True, blank=True + ) + ifrc_global_ops_coordinator_email = models.CharField( + verbose_name=_("IFRC global ops coordinator email"), max_length=255, null=True, blank=True + ) + ifrc_global_ops_coordinator_title = models.CharField( + verbose_name=_("IFRC global ops coordinator title"), max_length=255, null=True, blank=True + ) + ifrc_global_ops_coordinator_phone_number = models.CharField( + verbose_name=_("IFRC global ops coordinator phone number"), max_length=100, null=True, blank=True + ) + + # NOTE: Export files for EAPs, + export_file = SecureFileField( + verbose_name=_("EAP Export File"), + upload_to="eap/files/exports/", + null=True, + blank=True, + ) + + diff_file = SecureFileField( + verbose_name=_("EAP Diff PDF file"), + upload_to="eap/files/", + null=True, + blank=True, + ) + + # Review Checklist + + review_checklist_file = SecureFileField( + verbose_name=_("Review Checklist File"), + upload_to="eap/files/", + null=True, + blank=True, + ) + + updated_checklist_file = models.ForeignKey[EAPFile | None, EAPFile | None]( + EAPFile, + on_delete=models.SET_NULL, + verbose_name=_("Updated Review Checklist File"), + null=True, + blank=True, + ) + + # BUDGET # + + budget_file = models.ForeignKey[EAPFile, EAPFile]( + EAPFile, + on_delete=models.CASCADE, + verbose_name=_("Budget File"), + related_name="+", + ) + + total_budget = models.IntegerField( + verbose_name=_("Total Budget (CHF)"), + ) + readiness_budget = models.IntegerField( + verbose_name=_("Readiness Budget (CHF)"), + ) + pre_positioning_budget = models.IntegerField( + verbose_name=_("Pre-positioning Budget (CHF)"), + ) + early_action_budget = models.IntegerField( + verbose_name=_("Early Actions Budget (CHF)"), + ) + + # TYPING + budget_file_id: int + + class Meta: + abstract = True + + +class SimplifiedEAP(EAPBaseModel, CommonEAPFields): + """Model representing a Simplified EAP.""" + + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( + EAPRegistration, + on_delete=models.CASCADE, + verbose_name=_("EAP Development Registration"), + related_name="simplified_eaps", + ) + + seap_timeframe = models.IntegerField( + verbose_name=_("Timeframe (Years) of the EAP"), + help_text=_("Timeframe of the EAP in years."), + ) + + # RISK ANALYSIS and EARLY ACTION SELECTION # + + # RISK ANALYSIS # + prioritized_hazard_and_impact = models.TextField( + verbose_name=_("Prioritized Hazard and its historical impact."), + ) + hazard_impact_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Hazard Impact Images"), + related_name="simplified_eap_hazard_impact_images", + blank=True, + ) + + risks_selected_protocols = models.TextField( + verbose_name=_("Risk selected for the protocols."), + ) + + risk_selected_protocols_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Risk Selected Protocols Images"), + related_name="simplified_eap_risk_selected_protocols_images", + blank=True, + ) + + # EARLY ACTION SELECTION # + selected_early_actions = models.TextField( + verbose_name=_("Selected Early Actions"), + ) + selected_early_actions_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Selected Early Actions Images"), + related_name="simplified_eap_selected_early_actions_images", + blank=True, + ) + + # EARLY ACTION INTERVENTION # + overall_objective_intervention = models.TextField( + verbose_name=_("Overall objective of the intervention"), + help_text=_("Provide an objective statement that describe the main of the intervention."), + ) + + potential_geographical_high_risk_areas = models.TextField( + verbose_name=_("Potential geographical high-risk areas"), + ) + + assisted_through_operation = models.TextField( + verbose_name=_("Assisted through the operation"), + ) + selection_criteria = models.TextField( + verbose_name=_("Selection Criteria."), + help_text=_("Explain the selection criteria for who will be targeted"), + null=True, + blank=True, + ) + + trigger_statement = models.TextField( + verbose_name=_("Trigger Statement"), + null=True, + blank=True, + ) + + # NOTE: seap_lead_timeframe_unit and seap_lead_time are atomic + seap_lead_timeframe_unit = models.IntegerField( + choices=TimeFrame.choices, + verbose_name=_("sEAP Lead Timeframe Unit"), + ) + seap_lead_time = models.IntegerField( + verbose_name=_("sEAP Lead Time"), + ) + + # NOTE: operational_timeframe_unit and operational_time are atomic + # operational_timeframe is set default to Months + operational_timeframe_unit = models.IntegerField( + choices=TimeFrame.choices, + default=TimeFrame.MONTHS, + verbose_name=_("Operational Timeframe Unit"), + ) + operational_timeframe = models.IntegerField( + verbose_name=_("Operational Time"), + ) + + trigger_threshold_justification = models.TextField( + verbose_name=_("Trigger Threshold Justification"), + help_text=_("Explain how the trigger were set and provide information"), + ) + next_step_towards_full_eap = models.TextField( + verbose_name=_("Next Steps towards Full EAP"), + ) + + # PLANNED OPEATIONS # + planned_operations = models.ManyToManyField( + PlannedOperation, + verbose_name=_("Planned Operations"), + blank=True, + ) + + # ENABLING APPROACHES # + enabling_approaches = models.ManyToManyField( + EnablingApproach, + verbose_name=_("Enabling Approaches"), + related_name="simplified_eap_enabling_approaches", + blank=True, + ) + + # CONDITION TO DELIVER AND BUDGET # + + # RISK ANALYSIS # + + early_action_capability = models.TextField( + verbose_name=_("Experience or Capacity to implement Early Action."), + help_text=_("Assumptions or minimum conditions needed to deliver the early actions."), + ) + rcrc_movement_involvement = models.TextField( + verbose_name=_("RCRC Movement Involvement."), + help_text=_("RCRC Movement partners, Governmental/other agencies consulted/involved."), + ) + + # NOTE: Snapshot fields + version = models.IntegerField( + verbose_name=_("Version"), + help_text=_("Version identifier for the Simplified EAP."), + default=1, + ) + is_locked = models.BooleanField( + verbose_name=_("Is Locked?"), + help_text=_("Indicates whether the Simplified EAP is locked for editing."), + default=False, + ) + parent = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + verbose_name=_("Parent Simplified EAP"), + help_text=_("Reference to the parent Simplified EAP if this is a snapshot."), + null=True, + blank=True, + related_name="snapshots", + ) + + # TYPING + id: int + eap_registration_id: int + parent_id: int + + class Meta: + verbose_name = _("Simplified EAP") + verbose_name_plural = _("Simplified EAPs") + ordering = ["-id"] + constraints = [ + models.UniqueConstraint( + fields=["eap_registration", "version"], + name="unique_simplified_eap_version", + ) + ] + + def __str__(self): + return f"Simplified EAP for {self.eap_registration}- version:{self.version}" + + def generate_snapshot(self): + """ + Generate a snapshot of the given Simplified EAP. + """ + + from eap.utils import copy_model_instance + + # TODO(susilnem): Verify the fields to exclude? + with transaction.atomic(): + instance = copy_model_instance( + self, + overrides={ + "parent_id": self.id, + "version": self.version + 1, + "created_by_id": self.created_by_id, + "modified_by_id": self.modified_by_id, + "review_checklist_file": None, + "updated_checklist_file": None, + "diff_file": None, + "export_file": None, + }, + exclude_clone_m2m_fields={ + "admin2", + "cover_image", + "hazard_impact_images", + "risk_selected_protocols_images", + "selected_early_actions_images", + }, + ) + + # Setting Parent as locked + self.is_locked = True + self.save(update_fields=["is_locked"]) + return instance + + +class FullEAP(EAPBaseModel, CommonEAPFields): + """Model representing a Full EAP.""" + + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( + EAPRegistration, + on_delete=models.CASCADE, + verbose_name=_("EAP Development Registration"), + related_name="full_eaps", + ) + + expected_submission_time = models.DateField( + verbose_name=_("Expected submission time"), + help_text=_("Include the propose time of submission, accounting for the time it will take to deliver the application."), + ) + + objective = models.TextField( + verbose_name=_("Overall Objective of the EAP."), + help_text=_("Provide an objective statement that describe the main goal of intervention."), + ) + + # STAKEHOLDERS + is_worked_with_government = models.BooleanField( + verbose_name=_("Has Worked with government or other relevant actors."), + default=False, + ) + + worked_with_government_description = models.TextField( + verbose_name=_("Government and actors engagement description"), + null=True, + blank=True, + ) + + key_actors = models.ManyToManyField( + KeyActor, + verbose_name=_("Key Actors"), + related_name="full_eap_key_actor", + ) + + # TECHNICALLY WORKING GROUPS + is_technical_working_groups = models.BooleanField( + verbose_name=_("Are technical working groups in place"), + null=True, + blank=True, + ) + technically_working_group_title = models.CharField( + verbose_name=_("Technical working group title"), + max_length=255, + null=True, + blank=True, + ) + technical_working_groups_in_place_description = models.TextField( + verbose_name=_("Technical working groups description"), + null=True, + blank=True, + ) + + # RISK ANALYSIS # + hazard_selection = models.TextField( + verbose_name=_("Hazard selection"), + help_text=_("Provide a brief rationale for selecting this hazard for the FbF system."), + ) + + hazard_selection_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Hazard images"), + related_name="full_eap_hazard_selection_images", + blank=True, + ) + + exposed_element_and_vulnerability_factor = models.TextField( + verbose_name=_("Exposed elements and vulnerability factors"), + help_text=_("Explain which people are most likely to experience the impacts of this hazard."), + ) + + exposed_element_and_vulnerability_factor_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Exposed elements and vulnerability factors images"), + related_name="full_eap_vulnerability_factor_images", + blank=True, + ) + + prioritized_impact = models.TextField( + verbose_name=_("Prioritized impact"), + help_text=_("Describe the impacts that have been prioritized and who is most likely to be affected."), + ) + + prioritized_impacts = models.ManyToManyField( + EAPImpact, + verbose_name=_("Prioritized impacts"), + related_name="full_eap_prioritized_impacts", + ) + + prioritized_impact_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Prioritized impact images"), + related_name="full_eap_prioritized_impact_images", + blank=True, + ) + + risk_analysis_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Risk analysis relevant files"), + related_name="full_eap_risk_analysis_relevant_files", + ) + + risk_analysis_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Risk analysis source of information"), + related_name="risk_analysis_source_of_information", + blank=True, + ) + + # TRIGGER MODEL # + trigger_statement = models.TextField( + verbose_name=_("Trigger Statement"), + help_text=_("Explain in one sentence what exactly the trigger of your EAP will be."), + ) + + # NOTE: In days + lead_time = models.IntegerField(verbose_name=_("Lead Time")) + + trigger_statement_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Trigger Statement Source of Forecast"), + related_name="trigger_statement_source_of_information", + blank=True, + ) + + forecast_selection = models.TextField( + verbose_name=_("Forecast Selection"), + help_text=_("Explain which forecast's and observations will be used and why they are chosen"), + ) + + forecast_table_file = models.ForeignKey( + EAPFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("Forecast Table File"), + related_name="forecast_table_file", + ) + + forecast_selection_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Forecast Selection Images"), + related_name="+", + blank=True, + ) + + definition_and_justification_impact_level = models.TextField( + verbose_name=_("Definition and Justification of Impact Level"), + ) + + definition_and_justification_impact_level_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Definition and Justification Impact Level Images"), + related_name="+", + blank=True, + ) + + identification_of_the_intervention_area = models.TextField( + verbose_name=_("Identification of Intervention Area"), + ) + + identification_of_the_intervention_area_images = models.ManyToManyField( + EAPFile, + verbose_name=_("Intervention Area Images"), + related_name="+", + blank=True, + ) + + trigger_model_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Trigger Model Relevant File"), + related_name="full_eap_trigger_model_relevant_file", + ) + + trigger_model_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Target Model Source of Information"), + related_name="trigger_model_source_of_information", + blank=True, + ) + + # SELECTION OF ACTION + + early_actions = models.ManyToManyField( + EAPAction, + verbose_name=_("Early Actions"), + related_name="full_eap_early_actions", + ) + + early_action_selection_process = models.TextField( + verbose_name=_("Early action selection process"), + ) + + early_action_selection_process_images = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Early action selection process images"), + related_name="early_action_selection_process_images", + ) + # TODO(susilnem): Multiple files? + theory_of_change_table_file = models.ForeignKey[EAPFile | None, EAPFile | None]( + EAPFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("Theory of Change Table File"), + related_name="theory_of_change_table_file", + ) + + evidence_base = models.TextField( + verbose_name=_("Evidence base"), + help_text="Explain how the selected actions will reduce the expected disaster impacts.", + ) + + evidence_base_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Evidence base files"), + related_name="full_eap_evidence_base_relavent_files", + ) + + evidence_base_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Evidence base source of information"), + related_name="evidence_base_source_of_information", + blank=True, + ) + + # IFRC PLANNED ACTIONS + planned_operations = models.ManyToManyField( + PlannedOperation, + verbose_name=_("Planned operations"), + related_name="full_eap_planned_operation", + blank=True, + ) + enabling_approaches = models.ManyToManyField( + EnablingApproach, + verbose_name=_("Enabling approaches"), + related_name="full_eap_enabling_approaches", + blank=True, + ) + + usefulness_of_actions = models.TextField( + verbose_name=_("Usefulness of actions in case the event does not occur"), + help_text=_("Describe how actions will still benefit the population if the expected event does not occur."), + ) + + feasibility = models.TextField( + verbose_name=_("Feasibility of selected actions"), + help_text=_("Explain how feasible it is to implement the proposed early actions in the planned timeframe."), + ) + + # EAP ACTIVATION PROCESS + + early_action_implementation_process = models.TextField( + verbose_name=_("Early Action Implementation Process"), + help_text=_("Describe the process for implementing early actions."), + ) + + early_action_implementation_images = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Early Action Implementation Images"), + related_name="early_action_implementation_images", + ) + + trigger_activation_system = models.TextField( + verbose_name=_("Trigger Activation System"), + help_text=_("Describe the automatic system used to monitor the forecasts."), + ) + + trigger_activation_system_images = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Trigger Activation System Images"), + related_name="trigger_activation_system_images", + ) + + selection_of_target_population = models.TextField( + verbose_name=_("Selection of Target Population"), + help_text=_("Describe the process used to select the target population for early actions."), + ) + + stop_mechanism = models.TextField( + verbose_name=_("Stop Mechanism"), + help_text=_( + "Explain how it would be communicated to communities and stakeholders that the activities are being stopped." + ), + ) + + activation_process_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Activation Relevant Files"), + related_name="activation_process_relevant_files", + ) + + activation_process_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Activation Process Source of Information"), + related_name="activation_process_source_of_information", + blank=True, + ) + + # MEAL + + meal = models.TextField( + verbose_name=_("MEAL Plan Description"), + ) + meal_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Meal files"), + related_name="full_eap_meal_files", + ) + + # NATIONAL SOCIETY CAPACITY + operational_administrative_capacity = models.TextField( + verbose_name=_("National Society Operational, thematic and administrative capacity"), + help_text=_("Describe how the NS has operative and administrative capacity to implement the EAPs."), + ) + strategies_and_plans = models.TextField( + verbose_name=_("National Society Strategies and plans"), + help_text=_("Describe how the EAP aligned with disaster risk management strategy of NS."), + ) + advance_financial_capacity = models.TextField( + verbose_name=_("National Society Financial capacity to advance funds"), + help_text=_("Indicate whether the NS has capacity to advance funds to start early actions."), + ) + capacity_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("National society capacity relevant files"), + related_name="ns_capacity_relevant_files", + ) + + # FINANCE AND LOGISTICS + + budget_description = models.TextField(verbose_name=_("Full EAP Budget Description")) + readiness_cost_description = models.TextField(verbose_name=_("Readiness Cost Description")) + prepositioning_cost_description = models.TextField(verbose_name=_("Prepositioning Cost Description")) + early_action_cost_description = models.TextField(verbose_name=_("Early Action Cost Description")) + + # EAP ENDORSEMENT / APPROVAL + + eap_endorsement = models.TextField( + verbose_name=_("EAP Endorsement Description"), + help_text=("Describe by whom,how and when the EAP was agreed and endorsed."), + ) + + # NOTE: Snapshot fields + version = models.IntegerField( + verbose_name=_("Version"), + help_text=_("Version identifier for the Full EAP."), + default=1, + ) + is_locked = models.BooleanField( + verbose_name=_("Is Locked?"), + help_text=_("Indicates whether the Full EAP is locked for editing."), + default=False, + ) + parent = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + verbose_name=_("Parent FUll EAP"), + help_text=_("Reference to the parent Full EAP if this is a snapshot."), + null=True, + blank=True, + related_name="snapshots", + ) + + # TYPING + id: int + eap_registration_id: int + parent_id: int | None + + class Meta: + verbose_name = _("Full EAP") + verbose_name_plural = _("Full EAPs") + ordering = ["-id"] + constraints = [ + models.UniqueConstraint( + fields=["eap_registration", "version"], + name="unique_full_eap_version", + ) + ] + + def __str__(self): + return f"Full EAP for {self.eap_registration}- version:{self.version}" + + def generate_snapshot(self): + """ + Generate a snapshot of the given Full EAP. + """ + + from eap.utils import copy_model_instance + + with transaction.atomic(): + instance = copy_model_instance( + self, + overrides={ + "parent_id": self.id, + "version": self.version + 1, + "created_by_id": self.created_by_id, + "modified_by_id": self.modified_by_id, + "review_checklist_file": None, + "updated_checklist_file": None, + "diff_file": None, + "export_file": None, + }, + exclude_clone_m2m_fields={ + "admin2", + "cover_image", + # Files + "hazard_selection_images", + "theory_of_change_table_file", + "exposed_element_and_vulnerability_factor_images", + "prioritized_impact_images", + "risk_analysis_relevant_files", + "forecast_selection_images", + "definition_and_justification_impact_level_images", + "identification_of_the_intervention_area_images", + "trigger_model_relevant_files", + "early_action_selection_process_images", + "evidence_base_relevant_files", + "early_action_implementation_images", + "trigger_activation_system_images", + "activation_process_relevant_files", + "meal_relevant_files", + "capacity_relevant_files", + }, + ) + + # Setting Parent as locked + self.is_locked = True + self.save(update_fields=["is_locked"]) + return instance diff --git a/eap/permissions.py b/eap/permissions.py new file mode 100644 index 000000000..2b7b7ecfc --- /dev/null +++ b/eap/permissions.py @@ -0,0 +1,98 @@ +from django.contrib.auth.models import Permission +from rest_framework.permissions import BasePermission + +from api.models import Country +from eap.models import EAPRegistration + + +def has_country_permission( + user, + national_society_id: int, +) -> bool: + if user.is_superuser or user.has_perm("api.ifrc_admin"): + return True + + country_admin_ids = [ + int(codename.replace("country_admin_", "")) + for codename in Permission.objects.filter( + group__user=user, + codename__startswith="country_admin_", + ).values_list("codename", flat=True) + ] + + return national_society_id in country_admin_ids + + +def has_regional_permission( + user, + region_id: int, +) -> bool: + if user.is_superuser or user.has_perm("api.ifrc_admin"): + return True + + regional_admin_ids = [ + int(codename.replace("region_admin_", "")) + for codename in Permission.objects.filter( + group__user=user, + codename__startswith="region_admin_", + ).values_list("codename", flat=True) + ] + + return region_id in regional_admin_ids + + +class EAPRegistrationPermissions(BasePermission): + message = "You need to be country admin or IFRC admin or superuser to create/update EAP Registration" + + def has_permission(self, request, view) -> bool: + if request.method not in ["PUT", "PATCH", "POST"]: + return True + + user = request.user + national_society_id = request.data.get("national_society") + national_society = Country.objects.filter(id=national_society_id).first() + if not national_society: + return False + + return ( + user.is_superuser + or has_country_permission(user=user, national_society_id=national_society.pk) + or has_regional_permission( + user=user, + region_id=national_society.region.pk, + ) + ) + + +class EAPBasePermission(BasePermission): + message = "You don't have permission to create/update EAP" + + def has_object_permission(self, request, view, obj) -> bool: + if request.method not in ["PUT", "PATCH", "POST"]: + return True + + user = request.user + eap_reg_id = request.data.get("eap_registration", None) or obj.eap_registration_id + eap_registration = EAPRegistration.objects.filter(id=eap_reg_id).first() + + assert eap_registration is not None, "EAP Registration does not exist" + national_society_id = eap_registration.national_society_id + + return ( + user.is_superuser + or has_country_permission(user=user, national_society_id=national_society_id) + or has_regional_permission( + user=user, + region_id=eap_registration.national_society.region.pk, + ) + ) + + +class EAPValidatedBudgetPermission(BasePermission): + message = "You don't have permission to upload validated budget file for this EAP" + + def has_permission(self, request, view) -> bool: + user = request.user + if user.is_superuser or user.has_perm("api.ifrc_admin"): + return True + return False diff --git a/eap/serializers.py b/eap/serializers.py new file mode 100644 index 000000000..7d655c21b --- /dev/null +++ b/eap/serializers.py @@ -0,0 +1,1109 @@ +import typing +from datetime import timedelta + +from celery import group +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 +from rest_framework.exceptions import PermissionDenied + +from api.serializers import ( + Admin2Serializer, + DisasterTypeSerializer, + MiniCountrySerializer, + UserNameSerializer, +) +from eap.models import ( + DaysTimeFrameChoices, + EAPAction, + EAPContact, + EAPFile, + EAPImpact, + EAPRegistration, + EAPType, + EnablingApproach, + FullEAP, + HoursTimeFrameChoices, + Indicator, + KeyActor, + MonthsTimeFrameChoices, + OperationActivity, + PlannedOperation, + SimplifiedEAP, + SourceInformation, + TimeFrame, + YearsTimeFrameChoices, +) +from eap.tasks import ( + 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, + is_user_ifrc_admin, + validate_file_extention, +) +from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin +from utils.file_check import validate_file_type + +ALLOWED_FILE_EXTENTIONS: list[str] = ["pdf", "docx", "pptx", "xlsx", "xlsm"] + + +class BaseEAPSerializer(serializers.ModelSerializer): + def get_fields(self): + fields = super().get_fields() + # NOTE: Setting `created_by` and `modified_by` required to False + fields["created_by"] = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=False, + ) + fields["modified_by"] = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=False, + ) + + fields["created_by_details"] = UserNameSerializer(source="created_by", read_only=True) + fields["modified_by_details"] = UserNameSerializer(source="modified_by", read_only=True) + return fields + + def _set_user_fields(self, validated_data: dict[str, typing.Any], fields: list[str]) -> None: + """Set user fields if they exist in the model.""" + model_fields = self.Meta.model._meta._forward_fields_map + user = self.context["request"].user + + for field in fields: + if field in model_fields: + validated_data[field] = user + + def create(self, validated_data: dict[str, typing.Any]): + self._set_user_fields(validated_data, ["created_by", "modified_by"]) + return super().create(validated_data) + + def update(self, instance, validated_data: dict[str, typing.Any]): + self._set_user_fields(validated_data, ["modified_by"]) + return super().update(instance, validated_data) + + +class EAPFileInputSerializer(serializers.Serializer): + file = serializers.ListField(child=serializers.FileField(required=True)) + + +class EAPGlobalFilesSerializer(serializers.Serializer): + url = serializers.URLField(read_only=True) + + +class EAPFileSerializer(BaseEAPSerializer): + id = serializers.IntegerField(required=False) + file = serializers.FileField(required=True) + + class Meta: + model = EAPFile + fields = "__all__" + read_only_fields = ( + "created_by", + "modified_by", + ) + + def validate_file(self, file): + validate_file_type(file) + return file + + +# NOTE: Separate serializer for partial updating EAPFile instance +class EAPFileUpdateSerializer(BaseEAPSerializer): + id = serializers.IntegerField(required=True) + file = serializers.FileField(required=False) + + class Meta: + model = EAPFile + fields = "__all__" + read_only_fields = ( + "created_by", + "modified_by", + ) + + def validate_id(self, id: int) -> int: + try: + EAPFile.objects.get(id=id) + except EAPFile.DoesNotExist: + raise serializers.ValidationError(gettext("Invalid pk '%s' - object does not exist.") % id) + return id + + def validate_file(self, file): + validate_file_type(file) + return file + + +# NOTE: Mini Serializers used for basic listing purpose + + +class MiniSimplifiedEAPSerializer( + serializers.ModelSerializer, +): + updated_checklist_file_details = EAPFileSerializer(source="updated_checklist_file", read_only=True) + budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) + + class Meta: + model = SimplifiedEAP + fields = [ + "id", + "total_budget", + "readiness_budget", + "pre_positioning_budget", + "early_action_budget", + "seap_timeframe", + "budget_file", + "budget_file_details", + "version", + "is_locked", + "review_checklist_file", + "updated_checklist_file_details", + "created_at", + "modified_at", + ] + + +class MiniFullEAPSerializer( + serializers.ModelSerializer, +): + updated_checklist_file_details = EAPFileSerializer(source="updated_checklist_file", read_only=True) + budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) + + class Meta: + model = FullEAP + fields = [ + "id", + "total_budget", + "readiness_budget", + "pre_positioning_budget", + "early_action_budget", + "budget_file", + "budget_file_details", + "version", + "is_locked", + "review_checklist_file", + "updated_checklist_file_details", + "created_at", + "modified_at", + ] + + +class MiniEAPSerializer(serializers.ModelSerializer): + eap_type_display = serializers.CharField(source="get_eap_type_display", read_only=True) + country_details = MiniCountrySerializer(source="country", read_only=True) + disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) + status_display = serializers.CharField(source="get_status_display", read_only=True) + requirement_cost = serializers.IntegerField(read_only=True) + + class Meta: + model = EAPRegistration + fields = [ + "id", + "country", + "country_details", + "eap_type", + "eap_type_display", + "disaster_type", + "disaster_type_details", + "status", + "status_display", + "requirement_cost", + "activated_at", + "approved_at", + "created_at", + "modified_at", + ] + + +class EAPRegistrationSerializer( + NestedUpdateMixin, + NestedCreateMixin, + BaseEAPSerializer, +): + country_details = MiniCountrySerializer(source="country", read_only=True) + national_society_details = MiniCountrySerializer(source="national_society", read_only=True) + partners_details = MiniCountrySerializer(source="partners", many=True, read_only=True) + + eap_type_display = serializers.CharField(source="get_eap_type_display", read_only=True) + disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) + + # EAPs + simplified_eap_details = MiniSimplifiedEAPSerializer(source="simplified_eaps", many=True, read_only=True) + full_eap_details = MiniFullEAPSerializer(source="full_eaps", many=True, read_only=True) + + # Status + status_display = serializers.CharField(source="get_status_display", read_only=True) + + class Meta: + model = EAPRegistration + fields = "__all__" + read_only_fields = [ + "status", + "validated_budget_file", + "modified_at", + "created_by", + "modified_by", + "latest_simplified_eap", + "latest_full_eap", + "deadline", + "summary_file", + ] + + 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: + raise serializers.ValidationError("Cannot update EAP Registration once application is being created.") + return super().update(instance, validated_data) + + +class EAPValidatedBudgetFileSerializer(serializers.ModelSerializer): + validated_budget_file = serializers.FileField(required=True) + + class Meta: + model = EAPRegistration + fields = [ + "id", + "validated_budget_file", + ] + + def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: + assert self.instance is not None, "EAP instance does not exist." + if self.instance.get_status_enum != EAPRegistration.Status.TECHNICALLY_VALIDATED: + raise serializers.ValidationError( + gettext("Validated budget file can only be uploaded when EAP status is %s."), + EAPRegistration.Status.TECHNICALLY_VALIDATED.label, + ) + + validate_file_type(validated_data["validated_budget_file"]) + validate_file_extention(validated_data["validated_budget_file"].name, ALLOWED_FILE_EXTENTIONS) + return validated_data + + +ALLOWED_MAP_TIMEFRAMES_VALUE = { + TimeFrame.YEARS: list(YearsTimeFrameChoices.values), + TimeFrame.MONTHS: list(MonthsTimeFrameChoices.values), + TimeFrame.DAYS: list(DaysTimeFrameChoices.values), + TimeFrame.HOURS: list(HoursTimeFrameChoices.values), +} + + +class OperationActivitySerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + timeframe = serializers.ChoiceField( + choices=TimeFrame.choices, + required=True, + ) + timeframe_display = serializers.CharField(source="get_timeframe_display", read_only=True) + time_value = serializers.ListField( + child=serializers.IntegerField(), + required=True, + ) + + class Meta: + model = OperationActivity + fields = "__all__" + + # NOTE: Custom validation for `timeframe` and `time_value` + # Make sure time_value is within the allowed range for the selected timeframe + def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: + timeframe = validated_data["timeframe"] + time_value = validated_data["time_value"] + + allowed_values = ALLOWED_MAP_TIMEFRAMES_VALUE.get(timeframe, []) + invalid_values = [value for value in time_value if value not in allowed_values] + + if invalid_values: + raise serializers.ValidationError( + { + "time_value": gettext("Invalid time_value(s) %s for the selected timeframe %s.") + % (invalid_values, TimeFrame(timeframe).label) + } + ) + return validated_data + + +class IndicatorSerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + class Meta: + model = Indicator + fields = "__all__" + + +class PlannedOperationSerializer( + NestedUpdateMixin, + NestedCreateMixin, + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + sector_display = serializers.CharField(source="get_sector_display", read_only=True) + indicators = IndicatorSerializer(many=True, required=True) + + # activities + readiness_activities = OperationActivitySerializer(many=True, required=True) + prepositioning_activities = OperationActivitySerializer(many=True, required=True) + early_action_activities = OperationActivitySerializer(many=True, required=True) + + class Meta: + model = PlannedOperation + fields = "__all__" + + +class EnablingApproachSerializer( + NestedUpdateMixin, + NestedCreateMixin, + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + approach_display = serializers.CharField(source="get_approach_display", read_only=True) + indicators = IndicatorSerializer(many=True, required=True) + + # activities + readiness_activities = OperationActivitySerializer(many=True, required=True) + prepositioning_activities = OperationActivitySerializer(many=True, required=True) + early_action_activities = OperationActivitySerializer(many=True, required=True) + + class Meta: + model = EnablingApproach + fields = "__all__" + + +class EAPSourceInformationSerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + class Meta: + model = SourceInformation + fields = "__all__" + + +class KeyActorSerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + national_society_details = MiniCountrySerializer(source="national_society", read_only=True) + + class Meta: + model = KeyActor + fields = "__all__" + + +class EAPActionSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + class Meta: + model = EAPAction + fields = "__all__" + + +class ImpactSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + class Meta: + model = EAPImpact + fields = "__all__" + + +class EAPContactSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) + + class Meta: + model = EAPContact + fields = "__all__" + + +class CommonEAPFieldsSerializer(serializers.ModelSerializer): + MAX_NUMBER_OF_IMAGES = 5 + + def get_fields(self): + fields = super().get_fields() + fields["partner_contacts"] = EAPContactSerializer(many=True, required=False) + # TODO(susilnem): Make admin2 required once we verify the data! + fields["admin2_details"] = Admin2Serializer(source="admin2", many=True, read_only=True) + fields["cover_image_file"] = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) + fields["planned_operations"] = PlannedOperationSerializer(many=True, required=True) + fields["enabling_approaches"] = EnablingApproachSerializer(many=True, required=True) + fields["budget_file"] = serializers.PrimaryKeyRelatedField(queryset=EAPFile.objects.all(), required=True) + fields["budget_file_details"] = EAPFileSerializer(source="budget_file", read_only=True) + fields["updated_checklist_file_details"] = EAPFileSerializer(source="updated_checklist_file", read_only=True) + return fields + + def validate_budget_file(self, file: typing.Optional[EAPFile]) -> typing.Optional[EAPFile]: + if file is None: + return + + validate_file_extention(file.file.name, ALLOWED_FILE_EXTENTIONS) + return file + + def validate_updated_checklist_file(self, file): + if file is None: + return + + validate_file_extention(file.file.name, ALLOWED_FILE_EXTENTIONS) + validate_file_type(file.file) + return file + + def validate_images_field(self, field_name, images): + if images and len(images) > self.MAX_NUMBER_OF_IMAGES: + raise serializers.ValidationError( + {field_name: [f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed."]}, + ) + return images + + +class SimplifiedEAPSerializer( + NestedUpdateMixin, + NestedCreateMixin, + BaseEAPSerializer, + CommonEAPFieldsSerializer, +): + + # FILES + hazard_impact_images = EAPFileUpdateSerializer(required=False, many=True) + selected_early_actions_images = EAPFileUpdateSerializer(required=False, many=True, allow_null=True) + risk_selected_protocols_images = EAPFileUpdateSerializer(required=False, many=True, allow_null=True) + + # TimeFrame + seap_lead_timeframe_unit_display = serializers.CharField(source="get_seap_lead_timeframe_unit_display", read_only=True) + operational_timeframe_unit_display = serializers.CharField(source="get_operational_timeframe_unit_display", read_only=True) + + # IMAGES + + # NOTE: When adding new image fields, include their names in IMAGE_FIELDS below + # if the image fields are to be validated against the MAX_NUMBER_OF_IMAGES limit. + + IMAGE_FIELDS = [ + "hazard_impact_images", + "selected_early_actions_images", + "risk_selected_protocols_images", + ] + + class Meta: + model = SimplifiedEAP + read_only_fields = [ + "version", + "is_locked", + ] + exclude = ("cover_image",) + + def _validate_timeframe(self, data: dict[str, typing.Any]) -> None: + # --- seap lead TimeFrame --- + seap_unit = data.get("seap_lead_timeframe_unit") + seap_value = data.get("seap_lead_time") + + if (seap_unit is None) != (seap_value is None): + raise serializers.ValidationError( + {"seap_lead_timeframe_unit": gettext("seap lead timeframe and unit must both be provided.")} + ) + + if seap_unit is not None and seap_value is not None: + allowed_units = [ + TimeFrame.MONTHS, + TimeFrame.DAYS, + TimeFrame.HOURS, + ] + if seap_unit not in allowed_units: + raise serializers.ValidationError( + { + "seap_lead_timeframe_unit": gettext( + "seap lead timeframe unit must be one of the following: Months, Days, or Hours." + ) + } + ) + + # --- Operational TimeFrame --- + op_unit = data.get("operational_timeframe_unit") + op_value = data.get("operational_timeframe") + + # Require both if one is provided + if (op_unit is None) != (op_value is None): + raise serializers.ValidationError( + {"operational_timeframe_unit": gettext("operational timeframe and unit must both be provided.")} + ) + + if op_unit is not None and op_value is not None: + if op_unit != TimeFrame.MONTHS: + raise serializers.ValidationError( + {"operational_timeframe_unit": gettext("operational timeframe unit must be Months.")} + ) + + if op_value not in MonthsTimeFrameChoices: + raise serializers.ValidationError( + {"operational_timeframe": gettext("operational timeframe value is not valid for Months unit.")} + ) + + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: + original_eap_registration = getattr(self.instance, "eap_registration", None) if self.instance else None + eap_registration: EAPRegistration | None = data.get("eap_registration", original_eap_registration) + assert eap_registration is not None, "EAP Registration must be provided." + + if self.instance and original_eap_registration != eap_registration: + raise serializers.ValidationError("EAP Registration cannot be changed for existing EAP.") + + if not self.instance and eap_registration.has_eap_application: + raise serializers.ValidationError("Simplified EAP for this EAP registration already exists.") + + if self.instance and eap_registration.get_status_enum not in [ + EAPRegistration.Status.UNDER_DEVELOPMENT, + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + ]: + raise serializers.ValidationError( + gettext("Cannot update while EAP Application is in %s.") + % EAPRegistration.Status(eap_registration.get_status_enum).label + ) + + # NOTE: Cannot update locked Simplified EAP + if self.instance and self.instance.is_locked: + raise serializers.ValidationError("Cannot update locked EAP Application.") + + eap_type = eap_registration.get_eap_type_enum + if eap_type and eap_type != EAPType.SIMPLIFIED_EAP: + raise serializers.ValidationError("Cannot create Simplified EAP for non-simplified EAP registration.") + + # Validate timeframe fields + self._validate_timeframe(data) + + # Validate all image fields in one place + for field in self.IMAGE_FIELDS: + if field in data: + self.validate_images_field(field, data[field]) + return data + + def create(self, validated_data: dict[str, typing.Any]): + instance: SimplifiedEAP = super().create(validated_data) + instance.eap_registration.update_eap_type(EAPType.SIMPLIFIED_EAP) + instance.eap_registration.latest_simplified_eap = instance + instance.eap_registration.save(update_fields=["latest_simplified_eap"]) + return instance + + +class FullEAPSerializer( + NestedUpdateMixin, + NestedCreateMixin, + BaseEAPSerializer, + CommonEAPFieldsSerializer, +): + + # admins + key_actors = KeyActorSerializer(many=True, required=True) + + early_actions = EAPActionSerializer(many=True, required=True) + prioritized_impacts = ImpactSerializer(many=True, required=True) + + # SOURCE OF INFORMATIONS + risk_analysis_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) + trigger_statement_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) + trigger_model_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) + evidence_base_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) + activation_process_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) + + # IMAGES + hazard_selection_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + exposed_element_and_vulnerability_factor_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + prioritized_impact_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + forecast_selection_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + definition_and_justification_impact_level_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + identification_of_the_intervention_area_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + early_action_selection_process_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + early_action_implementation_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + trigger_activation_system_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, + ) + + # FILES + forecast_table_file_details = EAPFileSerializer(source="forecast_table_file", read_only=True) + forecast_table_file = serializers.PrimaryKeyRelatedField( + queryset=EAPFile.objects.all(), + required=True, + allow_null=False, + ) + + theory_of_change_table_file_details = EAPFileSerializer(source="theory_of_change_table_file", read_only=True) + risk_analysis_relevant_files_details = EAPFileSerializer(source="risk_analysis_relevant_files", many=True, read_only=True) + evidence_base_relevant_files_details = EAPFileSerializer(source="evidence_base_relevant_files", many=True, read_only=True) + activation_process_relevant_files_details = EAPFileSerializer( + source="activation_process_relevant_files", many=True, read_only=True + ) + trigger_model_relevant_files_details = EAPFileSerializer(source="trigger_model_relevant_files", many=True, read_only=True) + meal_relevant_files_details = EAPFileSerializer(source="meal_relevant_files", many=True, read_only=True) + capacity_relevant_files_details = EAPFileSerializer(source="capacity_relevant_files", many=True, read_only=True) + + # NOTE: When adding new image fields, include their names in IMAGE_FIELDS below + # if the image fields are to be validated against the MAX_NUMBER_OF_IMAGES limit. + + IMAGE_FIELDS = [ + "hazard_selection_images", + "exposed_element_and_vulnerability_factor_images", + "prioritized_impact_images", + "forecast_selection_images", + "definition_and_justification_impact_level_images", + "identification_of_the_intervention_area_images", + "early_action_selection_process_images", + "early_action_implementation_images", + "trigger_activation_system_images", + ] + + class Meta: + model = FullEAP + read_only_fields = ( + "created_by", + "modified_by", + ) + exclude = ("cover_image",) + + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: + original_eap_registration = getattr(self.instance, "eap_registration", None) if self.instance else None + eap_registration: EAPRegistration | None = data.get("eap_registration", original_eap_registration) + assert eap_registration is not None, "EAP Registration must be provided." + + if self.instance and original_eap_registration != eap_registration: + raise serializers.ValidationError("EAP Registration cannot be changed for existing EAP.") + + if not self.instance and eap_registration.has_eap_application: + raise serializers.ValidationError("Full EAP for this EAP registration already exists.") + + if self.instance and eap_registration.get_status_enum not in [ + EAPRegistration.Status.UNDER_DEVELOPMENT, + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + ]: + raise serializers.ValidationError( + gettext("Cannot update while EAP Application is in %s."), + EAPRegistration.Status(eap_registration.get_status_enum).label, + ) + + # NOTE: Cannot update locked Full EAP + if self.instance and self.instance.is_locked: + raise serializers.ValidationError("Cannot update locked EAP Application.") + + eap_type = eap_registration.get_eap_type_enum + if eap_type and eap_type != EAPType.FULL_EAP: + raise serializers.ValidationError("Cannot create Full EAP for non-full EAP registration.") + + # Validate all image fields in one place + for field in self.IMAGE_FIELDS: + if field in data: + self.validate_images_field(field, data[field]) + return data + + def create(self, validated_data: dict[str, typing.Any]): + instance: FullEAP = super().create(validated_data) + instance.eap_registration.update_eap_type(EAPType.FULL_EAP) + instance.eap_registration.latest_full_eap = instance + instance.eap_registration.save(update_fields=["latest_full_eap"]) + return instance + + +# STATUS TRANSITION SERIALIZER +VALID_NS_EAP_STATUS_TRANSITIONS = set( + [ + (EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.UNDER_REVIEW), + (EAPRegistration.Status.NS_ADDRESSING_COMMENTS, EAPRegistration.Status.UNDER_REVIEW), + ] +) + + +VALID_IFRC_EAP_STATUS_TRANSITIONS = set( + [ + (EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.UNDER_REVIEW), + (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), + (EAPRegistration.Status.NS_ADDRESSING_COMMENTS, EAPRegistration.Status.UNDER_REVIEW), + (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.TECHNICALLY_VALIDATED), + (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), + (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.PENDING_PFA), + (EAPRegistration.Status.PENDING_PFA, EAPRegistration.Status.APPROVED), + (EAPRegistration.Status.APPROVED, EAPRegistration.Status.ACTIVATED), + ] +) + + +class EAPStatusSerializer(BaseEAPSerializer): + status_display = serializers.CharField(source="get_status_display", read_only=True) + # NOTE: Only required when changing status to NS Addressing Comments + review_checklist_file = serializers.FileField(required=False) + + class Meta: + model = EAPRegistration + fields = [ + "id", + "status_display", + "status", + "review_checklist_file", + ] + + def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: + assert self.instance is not None, "EAP instance does not exist." + self.instance: EAPRegistration + + if not self.instance.has_eap_application: + raise serializers.ValidationError(gettext("You cannot change the status until EAP application has been created.")) + + user = self.context["request"].user + current_status: EAPRegistration.Status = self.instance.get_status_enum + new_status: EAPRegistration.Status = EAPRegistration.Status(validated_data.get("status")) + + valid_transitions = VALID_IFRC_EAP_STATUS_TRANSITIONS if is_user_ifrc_admin(user) else VALID_NS_EAP_STATUS_TRANSITIONS + + if (current_status, new_status) not in valid_transitions: + raise serializers.ValidationError( + gettext("EAP status cannot be changed from %s to %s.") + % (EAPRegistration.Status(current_status).label, EAPRegistration.Status(new_status).label) + ) + + if (current_status, new_status) == ( + EAPRegistration.Status.UNDER_DEVELOPMENT, + EAPRegistration.Status.UNDER_REVIEW, + ): + if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + transaction.on_commit( + lambda: generate_export_eap_pdf.delay( + eap_registration_id=self.instance.id, + version=self.instance.latest_simplified_eap.version, + ) + ) + else: + transaction.on_commit( + lambda: generate_export_eap_pdf.delay( + eap_registration_id=self.instance.id, + version=self.instance.latest_full_eap.version, + ) + ) + + # NOTE: IFRC Admins should be able to transition from TECHNICALLY_VALIDATED + # to NS_ADDRESSING_COMMENTS to allow NS users to update their EAP changes after validated budget has been set. + if (current_status, new_status) in [ + (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), + (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), + ]: + if not is_user_ifrc_admin(user): + raise PermissionDenied( + gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label + ) + + review_checklist_file = validated_data.get("review_checklist_file") + if not review_checklist_file: + raise serializers.ValidationError( + gettext("Review checklist file must be uploaded before changing status to %s.") + % EAPRegistration.Status(new_status).label + ) + + # latest EAP + if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + snapshot_instance = self.instance.latest_simplified_eap.generate_snapshot() + self.instance.latest_simplified_eap = snapshot_instance + snapshot_instance.review_checklist_file = review_checklist_file + snapshot_instance.save(update_fields=["review_checklist_file"]) + self.instance.save(update_fields=["latest_simplified_eap"]) + else: + snapshot_instance = self.instance.latest_full_eap.generate_snapshot() + self.instance.latest_full_eap = snapshot_instance + snapshot_instance.review_checklist_file = review_checklist_file + snapshot_instance.save(update_fields=["review_checklist_file"]) + self.instance.save(update_fields=["latest_full_eap"]) + + # NOTE: Clearing validated budget file, if changes to NS Addressing Comments. + if (current_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + ): + self.instance.validated_budget_file = None + self.instance.save(update_fields=["validated_budget_file"]) + + elif (current_status, new_status) == ( + EAPRegistration.Status.UNDER_REVIEW, + EAPRegistration.Status.TECHNICALLY_VALIDATED, + ): + if not is_user_ifrc_admin(user): + raise PermissionDenied( + gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label + ) + + # Update timestamp + self.instance.technically_validated_at = timezone.now() + self.instance.save( + update_fields=[ + "technically_validated_at", + ] + ) + + elif (current_status, new_status) == ( + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + EAPRegistration.Status.UNDER_REVIEW, + ): + if not (has_country_permission(user, self.instance.national_society_id) or is_user_ifrc_admin(user)): + raise PermissionDenied( + gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label + ) + + # Check latest EAP has NS Addressing Comments file uploaded + if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + if not (self.instance.latest_simplified_eap and self.instance.latest_simplified_eap.updated_checklist_file): + raise serializers.ValidationError( + gettext("NS Addressing Comments file must be uploaded before changing status to %s.") + % EAPRegistration.Status(new_status).label + ) + + # Generating PDFs asynchronously + transaction.on_commit( + lambda: group( + generate_export_eap_pdf.s( + eap_registration_id=self.instance.id, + version=self.instance.latest_simplified_eap.version, + ), + generate_export_diff_pdf.s( + eap_registration_id=self.instance.id, + version=self.instance.latest_simplified_eap.version, + ), + ).apply_async() + ) + + else: + if not (self.instance.latest_full_eap and self.instance.latest_full_eap.updated_checklist_file): + raise serializers.ValidationError( + gettext("NS Addressing Comments file must be uploaded before changing status to %s.") + % EAPRegistration.Status(new_status).label + ) + + # Generating PDFs asynchronously + transaction.on_commit( + lambda: group( + generate_export_eap_pdf.s( + eap_registration_id=self.instance.id, + version=self.instance.latest_full_eap.version, + ), + generate_export_diff_pdf.s( + eap_registration_id=self.instance.id, + version=self.instance.latest_full_eap.version, + ), + ).apply_async() + ) + + elif (current_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.PENDING_PFA, + ): + if not is_user_ifrc_admin(user): + raise PermissionDenied( + gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label + ) + + if not self.instance.validated_budget_file: + raise serializers.ValidationError( + gettext("Validated budget file must be uploaded before changing status to %s.") + % EAPRegistration.Status(new_status).label + ) + + # Update timestamp + self.instance.pending_pfa_at = timezone.now() + self.instance.save( + update_fields=[ + "pending_pfa_at", + ] + ) + + # Generate summary eap for full eap + if self.instance.get_eap_type_enum == EAPType.FULL_EAP: + transaction.on_commit(lambda: generate_eap_summary_pdf.delay(self.instance.id)) + + elif (current_status, new_status) == ( + EAPRegistration.Status.PENDING_PFA, + EAPRegistration.Status.APPROVED, + ): + # Update timestamp + self.instance.approved_at = timezone.now() + self.instance.save( + update_fields=[ + "approved_at", + ] + ) + + elif (current_status, new_status) == ( + EAPRegistration.Status.APPROVED, + EAPRegistration.Status.ACTIVATED, + ): + # Update timestamp + self.instance.activated_at = timezone.now() + self.instance.save( + update_fields=[ + "activated_at", + ] + ) + return validated_data + + def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: + self._validate_status(validated_data) + return validated_data + + def validate_review_checklist_file(self, file): + if file is None: + return + + validate_file_extention(file.name, ALLOWED_FILE_EXTENTIONS) + 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 + + # NOTE: Email Notifications + eap_registration_id = updated_instance.id + 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) in [ + (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), + (EAPRegistration.Status.TECHNICALLY_VALIDATED, 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 + - Also when the IFRC resubmits after technical validation, it will be version >= 3 + + 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.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 new file mode 100644 index 000000000..21aff9b27 --- /dev/null +++ b/eap/tasks.py @@ -0,0 +1,611 @@ +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: + timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + title = f"{eap_registration.national_society.name}-{eap_registration.disaster_type.name}" + return f"EAP-{title}-({timestamp}).pdf" + + +@shared_task +def generate_eap_summary_pdf(eap_registration_id): + 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, + summary=True, + ) + + logger.info(f"Starting EAP summary PDF generation: {eap_registration.pk}") + try: + file = render_pdf_from_url( + url=url, + user=user, + token=token, + ) + + file_name = build_filename(eap_registration) + eap_registration.summary_file.save(file_name, file) + + logger.info(f"EAP summary generation completed: {eap_registration.pk}") + + except Exception: + logger.error( + f"Failed to generate EAP summary PDF: {eap_registration.pk}", + exc_info=True, + extra=logger_context( + dict(eap_registration_id=eap_registration.pk), + ), + ) + + +@shared_task +def generate_export_diff_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, + diff=True, + version=version, + ) + + logger.info(f"Starting EAP diff PDF generation: {eap_registration.pk}") + try: + file = render_pdf_from_url( + url=url, + user=user, + token=token, + ) + + file_name = build_filename(eap_registration) + if eap_registration.eap_type == EAPType.SIMPLIFIED_EAP: + simplified_eap = SimplifiedEAP.objects.filter( + eap_registration=eap_registration, + version=version, + ).first() + if not simplified_eap: + raise ValueError("Simplified EAP version not found.") + + simplified_eap.diff_file.save(file_name, file) + else: + full_eap = FullEAP.objects.filter( + eap_registration=eap_registration, + version=version, + ).first() + if not full_eap: + raise ValueError("Full EAP version not found.") + + full_eap.diff_file.save(file_name, file) + + logger.info(f"EAP diff generation completed: {eap_registration.pk}") + + except Exception: + logger.error( + f"Failed to generate EAP diff PDF: {eap_registration.pk}", + exc_info=True, + extra=logger_context( + dict(eap_registration_id=eap_registration.pk), + ), + ) + + +@shared_task +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, + ) + + logger.info(f"Starting EAP export PDF generation: {eap_registration.pk}") + try: + file = render_pdf_from_url( + url=url, + user=user, + token=token, + ) + + file_name = build_filename(eap_registration) + if eap_registration.eap_type == EAPType.SIMPLIFIED_EAP: + simplified_eap = SimplifiedEAP.objects.filter( + eap_registration=eap_registration, + version=version, + ).first() + if not simplified_eap: + raise ValueError("Simplified EAP version not found.") + + simplified_eap.export_file.save(file_name, file) + else: + full_eap = FullEAP.objects.filter( + eap_registration=eap_registration, + version=version, + ).first() + if not full_eap: + raise ValueError("Full EAP version not found.") + + full_eap.export_file.save(file_name, file) + + logger.info(f"EAP export generation completed: {eap_registration.pk}") + + except Exception: + logger.error( + f"Failed to generate EAP export PDF: {eap_registration.pk}", + exc_info=True, + extra=logger_context( + 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 True + + +@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 True + + +@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 True + + +@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 True + + +@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 True + + +@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 True + + +@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 True + + +@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 True + + +@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 True diff --git a/eap/test_views.py b/eap/test_views.py new file mode 100644 index 000000000..e1d814de7 --- /dev/null +++ b/eap/test_views.py @@ -0,0 +1,2743 @@ +import os +import tempfile +from unittest import mock + +from django.conf import settings +from django.contrib.auth.models import Group, Permission +from django.core import management +from django.utils.translation import get_language as django_get_language + +from api.factories.country import CountryFactory +from api.factories.disaster_type import DisasterTypeFactory +from api.models import Export +from deployments.factories.user import UserFactory +from eap.factories import ( + EAPFileFactory, + EAPRegistrationFactory, + EnablingApproachFactory, + FullEAPFactory, + KeyActorFactory, + OperationActivityFactory, + PlannedOperationFactory, + SimplifiedEAPFactory, +) +from eap.models import ( + DaysTimeFrameChoices, + EAPFile, + EAPStatus, + EAPType, + EnablingApproach, + MonthsTimeFrameChoices, + PlannedOperation, + SimplifiedEAP, + TimeFrame, + YearsTimeFrameChoices, +) +from main.test_case import APITestCase + + +class EAPFileTestCase(APITestCase): + def setUp(self): + super().setUp() + + path = os.path.join(settings.TEST_DIR, "documents") + self.file = os.path.join(path, "go.png") + + def test_upload_file(self): + file_count = EAPFile.objects.count() + url = "/api/v2/eap-file/" + data = { + "file": open(self.file, "rb"), + } + self.authenticate() + response = self.client.post(url, data, format="multipart") + self.assert_201(response) + self.assertEqual(EAPFile.objects.count(), file_count + 1) + + def test_upload_multiple_file(self): + file_count = EAPFile.objects.count() + url = "/api/v2/eap-file/multiple/" + data = {"file": [open(self.file, "rb")]} + + self.authenticate() + response = self.client.post(url, data, format="multipart") + self.assert_201(response) + self.assertEqual(EAPFile.objects.count(), file_count + 1) + + def test_upload_invalid_files(self): + file_count = EAPFile.objects.count() + url = "/api/v2/eap-file/multiple/" + data = { + "file": [ + open(self.file, "rb"), + open(self.file, "rb"), + open(self.file, "rb"), + "test_string", + ] + } + + self.authenticate() + response = self.client.post(url, data, format="multipart") + self.assert_400(response) + # no new files to be created + self.assertEqual(EAPFile.objects.count(), file_count) + + +class EAPRegistrationTestCase(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="EAP", iso="EA") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="NSC", + iso="NS", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ", iso="P1") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA", iso="P2") + + # Create permissions + management.call_command("make_permissions") + + # Create Country Admin User and assign permission + 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) + + def test_list_eap_registration(self): + EAPRegistrationFactory.create_batch( + 5, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.country_admin, + modified_by=self.country_admin, + ) + url = "/api/v2/eap-registration/" + self.authenticate() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + + @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, + "country": self.country.id, + "national_society": self.national_society.id, + "disaster_type": self.disaster_type.id, + "expected_submission_time": "2024-12-31", + "partners": [self.partner1.id, self.partner2.id], + "national_society_contact_name": "National society contact name", + "national_society_contact_email": "test@example.com", + } + + self.authenticate(self.country_admin) + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, 201) + # Check created_by + self.assertIsNotNone(response.data["created_by_details"]) + self.assertEqual( + response.data["created_by_details"]["id"], + self.country_admin.id, + ) + self.assertEqual( + { + response.data["eap_type"], + response.data["status"], + response.data["country"], + response.data["disaster_type_details"]["id"], + }, + { + EAPType.FULL_EAP, + EAPStatus.UNDER_DEVELOPMENT, + self.country.id, + self.disaster_type.id, + }, + ) + self.assertTrue(send_new_eap_registration_email) + + def test_retrieve_eap_registration(self): + eap_registration = EAPRegistrationFactory.create( + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + ) + url = f"/api/v2/eap-registration/{eap_registration.id}/" + + self.authenticate(self.country_admin) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], eap_registration.id) + + def test_update_eap_registration(self): + eap_registration = EAPRegistrationFactory.create( + country=self.country, + eap_type=EAPType.SIMPLIFIED_EAP, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id], + created_by=self.country_admin, + modified_by=self.country_admin, + ) + url = f"/api/v2/eap-registration/{eap_registration.id}/" + + # Change Country and Partners + country2 = CountryFactory.create(name="country2", iso3="BBB") + partner3 = CountryFactory.create(name="partner3", iso3="CCC") + + data = { + "country": country2.id, + "national_society": self.national_society.id, + "disaster_type": self.disaster_type.id, + "expected_submission_time": "2025-01-15", + "partners": [self.partner2.id, partner3.id], + } + + # Authenticate as root user + self.authenticate(self.root_user) + response = self.client.patch(url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + + # Check modified_by + self.assertIsNotNone(response.data["modified_by_details"]) + self.assertEqual( + response.data["modified_by_details"]["id"], + self.root_user.id, + ) + + # Check country and partner + self.assertEqual(response.data["country_details"]["id"], country2.id) + self.assertEqual(len(response.data["partners_details"]), 2) + partner_ids = [p["id"] for p in response.data["partners_details"]] + self.assertIn(self.partner2.id, partner_ids) + + # Check cannot update EAP Registration once application is being created + 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, + ), + ) + + data_update = { + "country": self.country.id, + "national_society": self.national_society.id, + "disaster_type": self.disaster_type.id, + "expected_submission_time": "2025-02-01", + "partners": [self.partner1.id], + } + response = self.client.patch(url, data_update, format="json") + self.assertEqual(response.status_code, 400) + + def test_active_eaps(self): + eap_registration_1 = EAPRegistrationFactory.create( + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id], + created_by=self.country_admin, + modified_by=self.country_admin, + status=EAPStatus.APPROVED, + eap_type=EAPType.FULL_EAP, + ) + eap_registration_2 = EAPRegistrationFactory.create( + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + status=EAPStatus.ACTIVATED, + eap_type=EAPType.SIMPLIFIED_EAP, + ) + EAPRegistrationFactory.create( + country=self.country, + eap_type=None, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + status=EAPStatus.NS_ADDRESSING_COMMENTS, + ) + + EAPRegistrationFactory.create( + country=self.country, + eap_type=None, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + status=EAPStatus.UNDER_REVIEW, + ) + + full_eap_1 = FullEAPFactory.create( + eap_registration=eap_registration_1, + total_budget=5000, + 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, + ), + ) + + full_eap_snapshot_1 = FullEAPFactory.create( + eap_registration=eap_registration_1, + total_budget=10_000, + 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, + ), + parent_id=full_eap_1.id, + is_locked=True, + version=2, + ) + + full_eap_snapshot_2 = FullEAPFactory.create( + eap_registration=eap_registration_1, + total_budget=12_000, + 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, + ), + parent_id=full_eap_snapshot_1.id, + is_locked=False, + version=3, + ) + eap_registration_1.latest_full_eap = full_eap_snapshot_2 + eap_registration_1.save() + + simplifed_eap_1 = SimplifiedEAPFactory.create( + eap_registration=eap_registration_1, + created_by=self.country_admin, + modified_by=self.country_admin, + total_budget=5000, + budget_file=EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ), + ) + eap_registration_2.latest_simplified_eap = simplifed_eap_1 + eap_registration_2.save() + + simplifed_eap_snapshot_1 = SimplifiedEAPFactory.create( + eap_registration=eap_registration_2, + total_budget=10_000, + 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, + ), + parent_id=simplifed_eap_1.id, + is_locked=True, + version=2, + ) + + simplifed_eap_snapshot_2 = SimplifiedEAPFactory.create( + eap_registration=eap_registration_2, + total_budget=12_000, + 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, + ), + parent_id=simplifed_eap_snapshot_1.id, + is_locked=False, + version=3, + ) + eap_registration_2.latest_simplified_eap = simplifed_eap_snapshot_2 + eap_registration_2.save() + + url = "/api/v2/active-eap/" + self.authenticate() + response = self.client.get(url) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(response.data["results"]), 2, response.data["results"]) + + # Check requirement_cost values + # NOTE: it's the latest unlocked snapshot total_budget + self.assertEqual( + { + response.data["results"][0]["requirement_cost"], + response.data["results"][1]["requirement_cost"], + }, + { + full_eap_snapshot_2.total_budget, + simplifed_eap_snapshot_2.total_budget, + }, + ) + + +class EAPSimplifiedTestCase(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="EAP", iso="EA") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="NSC", + iso="NS", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ", iso="P1") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA", iso="P2") + + # Create permissions + management.call_command("make_permissions") + + # Create Country Admin User and assign permission + 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) + + def test_list_simplified_eap(self): + eap_registrations = EAPRegistrationFactory.create_batch( + 5, + eap_type=EAPType.SIMPLIFIED_EAP, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + for eap in eap_registrations: + SimplifiedEAPFactory.create( + eap_registration=eap, + 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, + ), + ) + + url = "/api/v2/simplified-eap/" + self.authenticate() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + + def test_create_simplified_eap(self): + url = "/api/v2/simplified-eap/" + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.SIMPLIFIED_EAP, + status=EAPStatus.UNDER_DEVELOPMENT, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + 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, + ) + + image_1 = EAPFileFactory._create_image( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + image_2 = EAPFileFactory._create_image( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + data = { + "eap_registration": eap_registration.id, + "national_society_contact_name": "National society contact name", + "national_society_contact_email": "test@example.com", + "ifrc_delegation_focal_point_name": "IFRC delegation focal point name", + "ifrc_delegation_focal_point_email": "test_ifrc@example.com", + "ifrc_head_of_delegation_name": "IFRC head of delegation name", + "ifrc_head_of_delegation_email": "ifrc_head@example.com", + "partner_contacts": [ + { + "name": "Partner 1 Contact", + "email": "partner1@example.com", + "title": "Partner 1 Title", + }, + { + "name": "Partner 2 Contact", + "email": "partner2@example.com", + "title": "Partner 2 Title", + }, + ], + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "risks_selected_protocols": "Protocol A and Protocol B.", + "selected_early_actions": "The early actions selected.", + "overall_objective_intervention": "To reduce risks through early actions.", + "potential_geographical_high_risk_areas": "Area 1, Area 2, and Area 3.", + "trigger_threshold_justification": "Based on historical data and expert analysis.", + "early_action_capability": "High capability with trained staff.", + "rcrc_movement_involvement": "Involves multiple RCRC societies.", + "assisted_through_operation": "5000", + "budget_file": budget_file.id, + "hazard_impact_images": [ + { + "id": image_1.id, + "caption": "Image 1 caption", + }, + { + "id": image_2.id, + "caption": "Image 2 caption", + }, + ], + "selected_early_actions_images": [ + { + "id": image_1.id, + "caption": "Image 1 caption for early actions", + }, + { + "id": image_2.id, + "caption": "Image 2 caption for early actions", + }, + ], + "total_budget": 10000, + "seap_timeframe": 3, + "seap_lead_timeframe_unit": TimeFrame.MONTHS, + "seap_lead_time": 6, + "operational_timeframe_unit": TimeFrame.MONTHS, + "operational_timeframe": 12, + "readiness_budget": 3000, + "pre_positioning_budget": 4000, + "early_action_budget": 3000, + "people_targeted": 5000, + "next_step_towards_full_eap": "Plan to expand.", + "planned_operations": [ + { + "sector": PlannedOperation.Sector.SETTLEMENT_AND_HOUSING, + "ap_code": 111, + "people_targeted": 10000, + "budget_per_sector": 100000, + "indicators": [ + { + "title": "indicator 1", + "target": 100, + }, + { + "title": "indicator 2", + "target": 200, + }, + ], + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.ONE_YEAR, + YearsTimeFrameChoices.TWO_YEARS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, + ], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.FIVE_YEARS], + } + ], + } + ], + "enabling_approaches": [ + { + "ap_code": 11, + "approach": EnablingApproach.Approach.SECRETARIAT_SERVICES, + "budget_per_approach": 10000, + "indicators": [ + { + "title": "indicator enable approach 1", + "target": 100, + }, + { + "title": "indicator enable approach 2", + "target": 200, + }, + ], + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.THREE_YEARS], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.FIVE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, + ], + } + ], + }, + ], + } + + self.authenticate(self.country_admin) + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 201, response.data) + + self.assertEqual( + response.data["eap_registration"], + eap_registration.id, + ) + self.assertEqual( + eap_registration.get_eap_type_enum, + EAPType.SIMPLIFIED_EAP, + ) + + # Check latest simplified EAP in registration + eap_registration.refresh_from_db() + self.assertEqual( + eap_registration.latest_simplified_eap.id, + response.data["id"], + ) + + # Cannot create Simplified EAP for the same EAP Registration again + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 400) + + def test_update_simplified_eap(self): + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.SIMPLIFIED_EAP, + status=EAPStatus.UNDER_DEVELOPMENT, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + ) + enabling_approach_readiness_operation_activity_1 = OperationActivityFactory.create( + activity="Readiness Activity 1", + timeframe=TimeFrame.MONTHS, + time_value=[MonthsTimeFrameChoices.ONE_MONTH, MonthsTimeFrameChoices.TWO_MONTHS], + ) + enabling_approach_readiness_operation_activity_2 = OperationActivityFactory.create( + activity="Readiness Activity 2", + timeframe=TimeFrame.YEARS, + time_value=[YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.FIVE_YEARS], + ) + enabling_approach_prepositioning_operation_activity_1 = OperationActivityFactory.create( + activity="Prepositioning Activity 1", + timeframe=TimeFrame.MONTHS, + time_value=[ + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.FOUR_MONTHS, + ], + ) + enabling_approach_prepositioning_operation_activity_2 = OperationActivityFactory.create( + activity="Prepositioning Activity 2", + timeframe=TimeFrame.MONTHS, + time_value=[ + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + ], + ) + enabling_approach_early_action_operation_activity_1 = OperationActivityFactory.create( + activity="Early Action Activity 1", + timeframe=TimeFrame.DAYS, + time_value=[DaysTimeFrameChoices.FIVE_DAYS, DaysTimeFrameChoices.TEN_DAYS], + ) + enabling_approach_early_action_operation_activity_2 = OperationActivityFactory.create( + activity="Early Action Activity 2", + timeframe=TimeFrame.MONTHS, + time_value=[ + MonthsTimeFrameChoices.ONE_MONTH, + MonthsTimeFrameChoices.THREE_MONTHS, + ], + ) + + # ENABLE APPROACH with activities + enabling_approach = EnablingApproachFactory.create( + approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, + budget_per_approach=5000, + ap_code=123, + readiness_activities=[ + enabling_approach_readiness_operation_activity_1.id, + enabling_approach_readiness_operation_activity_2.id, + ], + prepositioning_activities=[ + enabling_approach_prepositioning_operation_activity_1.id, + enabling_approach_prepositioning_operation_activity_2.id, + ], + early_action_activities=[ + enabling_approach_early_action_operation_activity_1.id, + enabling_approach_early_action_operation_activity_2.id, + ], + ) + planned_operation_readiness_operation_activity_1 = OperationActivityFactory.create( + activity="Readiness Activity 1", + timeframe=TimeFrame.MONTHS, + time_value=[MonthsTimeFrameChoices.ONE_MONTH, MonthsTimeFrameChoices.FOUR_MONTHS], + ) + planned_operation_readiness_operation_activity_2 = OperationActivityFactory.create( + activity="Readiness Activity 2", + timeframe=TimeFrame.YEARS, + time_value=[YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.THREE_YEARS], + ) + planned_operation_prepositioning_operation_activity_1 = OperationActivityFactory.create( + activity="Prepositioning Activity 1", + timeframe=TimeFrame.MONTHS, + time_value=[ + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.FOUR_MONTHS, + ], + ) + planned_operation_prepositioning_operation_activity_2 = OperationActivityFactory.create( + activity="Prepositioning Activity 2", + timeframe=TimeFrame.MONTHS, + time_value=[ + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + ], + ) + planned_operation_early_action_operation_activity_1 = OperationActivityFactory.create( + activity="Early Action Activity 1", + timeframe=TimeFrame.DAYS, + time_value=[DaysTimeFrameChoices.FIVE_DAYS, DaysTimeFrameChoices.TEN_DAYS], + ) + planned_operation_early_action_operation_activity_2 = OperationActivityFactory.create( + activity="Early Action Activity 2", + timeframe=TimeFrame.MONTHS, + time_value=[ + MonthsTimeFrameChoices.ONE_MONTH, + MonthsTimeFrameChoices.THREE_MONTHS, + ], + ) + + # PLANNED OPERATION with activities + planned_operation = PlannedOperationFactory.create( + sector=PlannedOperation.Sector.SHELTER, + ap_code=456, + people_targeted=5000, + budget_per_sector=50000, + readiness_activities=[ + planned_operation_readiness_operation_activity_1.id, + planned_operation_readiness_operation_activity_2.id, + ], + prepositioning_activities=[ + planned_operation_prepositioning_operation_activity_1.id, + planned_operation_prepositioning_operation_activity_2.id, + ], + early_action_activities=[ + planned_operation_early_action_operation_activity_1.id, + planned_operation_early_action_operation_activity_2.id, + ], + ) + + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=eap_registration, + created_by=self.country_admin, + modified_by=self.country_admin, + seap_lead_timeframe_unit=TimeFrame.MONTHS, + seap_lead_time=12, + operational_timeframe=12, + operational_timeframe_unit=TimeFrame.MONTHS, + budget_file=EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ), + enabling_approaches=[enabling_approach.id], + planned_operations=[planned_operation.id], + ) + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + + data = { + "eap_registration": eap_registration.id, + "total_budget": 20000, + "readiness_budget": 8000, + "pre_positioning_budget": 7000, + "early_action_budget": 5000, + "enabling_approaches": [ + { + "id": enabling_approach.id, + "approach": EnablingApproach.Approach.NATIONAL_SOCIETY_STRENGTHENING, + "budget_per_approach": 8000, + "ap_code": 123, + "readiness_activities": [ + { + "id": enabling_approach_readiness_operation_activity_1.id, + "activity": "Updated Enabling Approach Readiness Activity 1", + "timeframe": TimeFrame.MONTHS, + "time_value": [MonthsTimeFrameChoices.TWO_MONTHS], + } + ], + "prepositioning_activities": [ + { + "id": enabling_approach_prepositioning_operation_activity_1.id, + "activity": "Updated Enabling Approach Prepositioning Activity 1", + "timeframe": TimeFrame.MONTHS, + "time_value": [MonthsTimeFrameChoices.FOUR_MONTHS], + } + ], + "early_action_activities": [ + { + "id": enabling_approach_early_action_operation_activity_1.id, + "activity": "Updated Enabling Approach Early Action Activity 1", + "timeframe": TimeFrame.DAYS, + "time_value": [DaysTimeFrameChoices.TEN_DAYS], + } + ], + }, + # CREATE NEW Enabling Approach + { + "approach": EnablingApproach.Approach.PARTNERSHIP_AND_COORDINATION, + "budget_per_approach": 9000, + "ap_code": 124, + "readiness_activities": [ + { + "activity": "New Enabling Approach Readiness Activity", + "timeframe": TimeFrame.MONTHS, + "time_value": [ + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "New Enabling Approach Prepositioning Activity", + "timeframe": TimeFrame.MONTHS, + "time_value": [ + MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.NINE_MONTHS, + ], + } + ], + "early_action_activities": [ + { + "activity": "New Enabling Approach Early Action Activity", + "timeframe": TimeFrame.DAYS, + "time_value": [ + DaysTimeFrameChoices.EIGHT_DAYS, + DaysTimeFrameChoices.SIXTEEN_DAYS, + ], + } + ], + }, + ], + "planned_operations": [ + { + "id": planned_operation.id, + "sector": PlannedOperation.Sector.SHELTER, + "ap_code": 456, + "people_targeted": 8000, + "budget_per_sector": 80000, + "indicators": [ + { + "title": "indicator 1", + "target": 100, + }, + { + "title": "indicator 2", + "target": 200, + }, + ], + "readiness_activities": [ + { + "id": planned_operation_readiness_operation_activity_1.id, + "activity": "Updated Planned Operation Readiness Activity 1", + "timeframe": TimeFrame.MONTHS, + "time_value": [ + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + ], + } + ], + "prepositioning_activities": [ + { + "id": planned_operation_prepositioning_operation_activity_1.id, + "activity": "Updated Planned Operation Prepositioning Activity 1", + "timeframe": TimeFrame.MONTHS, + "time_value": [ + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + ], + } + ], + "early_action_activities": [ + { + "id": planned_operation_early_action_operation_activity_1.id, + "activity": "Updated Planned Operation Early Action Activity 1", + "timeframe": TimeFrame.DAYS, + "time_value": [ + DaysTimeFrameChoices.EIGHT_DAYS, + DaysTimeFrameChoices.SIXTEEN_DAYS, + ], + } + ], + }, + { + # CREATE NEW Planned OperationActivity + "sector": PlannedOperation.Sector.HEALTH_AND_CARE, + "ap_code": 457, + "people_targeted": 6000, + "budget_per_sector": 60000, + "readiness_activities": [ + { + "activity": "New Planned Operation Readiness Activity", + "timeframe": TimeFrame.MONTHS, + "time_value": [ + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "New Planned Operation Prepositioning Activity", + "timeframe": TimeFrame.MONTHS, + "time_value": [ + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.FIVE_MONTHS, + ], + } + ], + "early_action_activities": [ + { + "activity": "New Planned Operation Early Action Activity", + "timeframe": TimeFrame.DAYS, + "time_value": [ + MonthsTimeFrameChoices.FIVE_MONTHS, + MonthsTimeFrameChoices.TWELVE_MONTHS, + ], + } + ], + }, + ], + } + + # Authenticate as root user + self.authenticate(self.root_user) + response = self.client.patch(url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + response.data["eap_registration"], + eap_registration.id, + ) + + # Check modified_by + self.assertIsNotNone(response.data["modified_by_details"]) + self.assertEqual( + response.data["modified_by_details"]["id"], + self.root_user.id, + ) + + # CHECK ENABLE APPROACH UPDATED + self.assertEqual(len(response.data["enabling_approaches"]), 2) + self.assertEqual( + { + response.data["enabling_approaches"][0]["id"], + response.data["enabling_approaches"][0]["approach"], + response.data["enabling_approaches"][0]["budget_per_approach"], + response.data["enabling_approaches"][0]["ap_code"], + # NEW DATA + response.data["enabling_approaches"][1]["approach"], + response.data["enabling_approaches"][1]["budget_per_approach"], + response.data["enabling_approaches"][1]["ap_code"], + }, + { + enabling_approach.id, + data["enabling_approaches"][0]["approach"], + data["enabling_approaches"][0]["budget_per_approach"], + data["enabling_approaches"][0]["ap_code"], + # NEW DATA + data["enabling_approaches"][1]["approach"], + data["enabling_approaches"][1]["budget_per_approach"], + data["enabling_approaches"][1]["ap_code"], + }, + ) + self.assertEqual( + { + # READINESS ACTIVITY + response.data["enabling_approaches"][0]["readiness_activities"][0]["id"], + response.data["enabling_approaches"][0]["readiness_activities"][0]["activity"], + response.data["enabling_approaches"][0]["readiness_activities"][0]["timeframe"], + # NEW READINESS ACTIVITY + response.data["enabling_approaches"][1]["readiness_activities"][0]["activity"], + response.data["enabling_approaches"][1]["readiness_activities"][0]["timeframe"], + # PREPOSITIONING ACTIVITY + response.data["enabling_approaches"][0]["prepositioning_activities"][0]["id"], + response.data["enabling_approaches"][0]["prepositioning_activities"][0]["activity"], + response.data["enabling_approaches"][0]["prepositioning_activities"][0]["timeframe"], + # NEW PREPOSITIONING ACTIVITY + response.data["enabling_approaches"][1]["prepositioning_activities"][0]["activity"], + response.data["enabling_approaches"][1]["prepositioning_activities"][0]["timeframe"], + # EARLY ACTION ACTIVITY + response.data["enabling_approaches"][0]["early_action_activities"][0]["id"], + response.data["enabling_approaches"][0]["early_action_activities"][0]["activity"], + response.data["enabling_approaches"][0]["early_action_activities"][0]["timeframe"], + # NEW EARLY ACTION ACTIVITY + response.data["enabling_approaches"][1]["early_action_activities"][0]["activity"], + response.data["enabling_approaches"][1]["early_action_activities"][0]["timeframe"], + }, + { + # READINESS ACTIVITY + enabling_approach_readiness_operation_activity_1.id, + data["enabling_approaches"][0]["readiness_activities"][0]["activity"], + data["enabling_approaches"][0]["readiness_activities"][0]["timeframe"], + # NEW READINESS ACTIVITY + data["enabling_approaches"][1]["readiness_activities"][0]["activity"], + data["enabling_approaches"][1]["readiness_activities"][0]["timeframe"], + # PREPOSITIONING ACTIVITY + enabling_approach_prepositioning_operation_activity_1.id, + data["enabling_approaches"][0]["prepositioning_activities"][0]["activity"], + data["enabling_approaches"][0]["prepositioning_activities"][0]["timeframe"], + # NEW PREPOSITIONING Activity + data["enabling_approaches"][1]["prepositioning_activities"][0]["activity"], + data["enabling_approaches"][1]["prepositioning_activities"][0]["timeframe"], + # EARLY ACTION ACTIVITY + enabling_approach_early_action_operation_activity_1.id, + data["enabling_approaches"][0]["early_action_activities"][0]["activity"], + data["enabling_approaches"][0]["early_action_activities"][0]["timeframe"], + # NEW EARLY ACTION ACTIVITY + data["enabling_approaches"][1]["early_action_activities"][0]["activity"], + data["enabling_approaches"][1]["early_action_activities"][0]["timeframe"], + }, + ) + + # CHECK PLANNED OPERATION UPDATED + self.assertEqual(len(response.data["planned_operations"]), 2) + self.assertEqual( + { + response.data["planned_operations"][0]["id"], + response.data["planned_operations"][0]["sector"], + response.data["planned_operations"][0]["ap_code"], + response.data["planned_operations"][0]["people_targeted"], + response.data["planned_operations"][0]["budget_per_sector"], + # NEW DATA + response.data["planned_operations"][1]["sector"], + response.data["planned_operations"][1]["ap_code"], + response.data["planned_operations"][1]["people_targeted"], + response.data["planned_operations"][1]["budget_per_sector"], + }, + { + planned_operation.id, + data["planned_operations"][0]["sector"], + data["planned_operations"][0]["ap_code"], + data["planned_operations"][0]["people_targeted"], + data["planned_operations"][0]["budget_per_sector"], + # NEW DATA + data["planned_operations"][1]["sector"], + data["planned_operations"][1]["ap_code"], + data["planned_operations"][1]["people_targeted"], + data["planned_operations"][1]["budget_per_sector"], + }, + ) + + self.assertEqual( + { + # READINESS ACTIVITY + response.data["planned_operations"][0]["readiness_activities"][0]["id"], + response.data["planned_operations"][0]["readiness_activities"][0]["activity"], + response.data["planned_operations"][0]["readiness_activities"][0]["timeframe"], + # NEW READINESS ACTIVITY + response.data["planned_operations"][1]["readiness_activities"][0]["activity"], + response.data["planned_operations"][1]["readiness_activities"][0]["timeframe"], + # PREPOSITIONING ACTIVITY + response.data["planned_operations"][0]["prepositioning_activities"][0]["id"], + response.data["planned_operations"][0]["prepositioning_activities"][0]["activity"], + response.data["planned_operations"][0]["prepositioning_activities"][0]["timeframe"], + # NEW PREPOSITIONING ACTIVITY + response.data["planned_operations"][1]["prepositioning_activities"][0]["activity"], + response.data["planned_operations"][1]["prepositioning_activities"][0]["timeframe"], + # EARLY ACTION ACTIVITY + response.data["planned_operations"][0]["early_action_activities"][0]["id"], + response.data["planned_operations"][0]["early_action_activities"][0]["activity"], + response.data["planned_operations"][0]["early_action_activities"][0]["timeframe"], + # NEW EARLY ACTION ACTIVITY + response.data["planned_operations"][1]["early_action_activities"][0]["activity"], + response.data["planned_operations"][1]["early_action_activities"][0]["timeframe"], + }, + { + # READINESS ACTIVITY + planned_operation_readiness_operation_activity_1.id, + data["planned_operations"][0]["readiness_activities"][0]["activity"], + data["planned_operations"][0]["readiness_activities"][0]["timeframe"], + # NEW READINESS ACTIVITY + data["planned_operations"][1]["readiness_activities"][0]["activity"], + data["planned_operations"][1]["readiness_activities"][0]["timeframe"], + # PREPOSITIONING ACTIVITY + planned_operation_prepositioning_operation_activity_1.id, + data["planned_operations"][0]["prepositioning_activities"][0]["activity"], + data["planned_operations"][0]["prepositioning_activities"][0]["timeframe"], + # NEW PREPOSITIONING ACTIVITY + data["planned_operations"][1]["prepositioning_activities"][0]["activity"], + data["planned_operations"][1]["prepositioning_activities"][0]["timeframe"], + # EARLY ACTION Activity + planned_operation_early_action_operation_activity_1.id, + data["planned_operations"][0]["early_action_activities"][0]["activity"], + data["planned_operations"][0]["early_action_activities"][0]["timeframe"], + # NEW EARLY ACTION Activity + data["planned_operations"][1]["early_action_activities"][0]["activity"], + data["planned_operations"][1]["early_action_activities"][0]["timeframe"], + }, + ) + + +class EAPStatusTransitionTestCase(APITestCase): + def setUp(self): + super().setUp() + + self.country = CountryFactory.create(name="country1", iso3="EAP", iso="EA") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="NSC", + iso="NS", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ", iso="ZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA", iso="AA") + + self.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, + ) + self.url = f"/api/v2/eap-registration/{self.eap_registration.id}/status/" + + def test_status_transition(self): + # Create permissions + management.call_command("make_permissions") + + # Create Country Admin User and assign permission + 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) + + # NOTE: Transition to UNDER REVIEW + # UNDER_DEVELOPMENT -> UNDER_REVIEW + data = { + "status": EAPStatus.UNDER_REVIEW, + } + self.authenticate() + + # FAILS: As User is not country admin or IFRC admin or superuser + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # Authenticate as country admin user + self.authenticate(self.country_admin) + + # FAILS: As no Simplified or Full EAP created yet + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=self.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, + ), + ) + self.eap_registration.latest_simplified_eap = simplified_eap + self.eap_registration.save() + + # SUCCESS: As Simplified EAP exists + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + + # NOTE: Check if the NS can update after changing to UNDER_REVIEW + # FAILS: As simplified EAP is in UNDER_REVIEW, cannot update + self.authenticate(self.country_admin) + update_data = { + "total_budget": 15000, + "readiness_budget": 5000, + "pre_positioning_budget": 5000, + "early_action_budget": 5000, + } + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400) + + # NOTE: Transition to NS_ADDRESSING_COMMENTS + # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + } + + # FAILS: As country admin cannot + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # NOTE: Login as IFRC admin user + # FAILS: As review_checklist_file is required + self.authenticate(self.ifrc_admin_user) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # Uploading review checklist file + # Create a temporary .xlsx file for testing + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data["review_checklist_file"] = tmp_file + + response = self.client.post(self.url, data, format="multipart") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + + self.eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.latest_simplified_eap.review_checklist_file, + ) + + # NOTE: Check if snapshot is created or not + # First SimplifedEAP should be locked + simplified_eap.refresh_from_db() + self.assertTrue(simplified_eap.is_locked) + + # Two SimplifiedEAP should be there + eap_simplified_queryset = SimplifiedEAP.objects.filter( + eap_registration=self.eap_registration, + ) + + self.assertEqual( + eap_simplified_queryset.count(), + 2, + "There should be two snapshots created.", + ) + + # Check version of the latest snapshot + # Version should be 2 + second_snapshot = eap_simplified_queryset.order_by("-version").first() + assert second_snapshot is not None, "Second snapshot should not be None." + + self.assertEqual( + second_snapshot.version, + 2, + "Latest snapshot version should be 2.", + ) + # Check for parent_id + self.assertEqual( + second_snapshot.parent_id, + simplified_eap.id, + "Latest snapshot's parent_id should be the first SimplifiedEAP id.", + ) + # Snapshot Shouldn't have the updated checklist file + self.assertFalse( + second_snapshot.updated_checklist_file, + "Latest Snapshot shouldn't have the updated checklist file.", + ) + # Check if the latest_simplified_eap is updated in EAPRegistration + self.assertEqual( + self.eap_registration.latest_simplified_eap.id, + second_snapshot.id, + ) + + # NOTE: Transition to UNDER_REVIEW + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + data = { + "status": EAPStatus.UNDER_REVIEW, + } + + # FAILS: As updated checklist file is required to go back to UNDER_REVIEW + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # Upload updated checklist file + # UPDATES on the second snapshot + url = f"/api/v2/simplified-eap/{second_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": second_snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + + response = self.client.patch(url, file_data, format="json") + self.assertEqual(response.status_code, 200, response.data) + + # SUCCESS: + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + + # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS + # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + } + + # SUCCESS: As only ifrc admins or superuser can + self.authenticate(self.ifrc_admin_user) + + # Uploading checklist file + # Create a temporary .xlsx file for testing + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data["review_checklist_file"] = tmp_file + + response = self.client.post(self.url, data, format="multipart") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + + # Check if three snapshots are created now + eap_simplified_queryset = SimplifiedEAP.objects.filter( + eap_registration=self.eap_registration, + ) + self.assertEqual( + eap_simplified_queryset.count(), + 3, + "There should be three snapshots created.", + ) + + # Check version of the latest snapshot + # Version should be 3 + third_snapshot = eap_simplified_queryset.order_by("-version").first() + assert third_snapshot is not None, "Third snapshot should not be None." + + self.assertEqual( + third_snapshot.version, + 3, + "Latest snapshot version should be 3.", + ) + # Check for parent_id + self.assertEqual( + third_snapshot.parent_id, + second_snapshot.id, + "Latest snapshot's parent_id should be the second Snapshot id.", + ) + + # Check if the second snapshot is locked. + second_snapshot.refresh_from_db() + self.assertTrue(second_snapshot.is_locked) + # Snapshot Shouldn't have the updated checklist file + self.assertFalse( + third_snapshot.updated_checklist_file, + "Latest snapshot shouldn't have the updated checklist file.", + ) + + # Check if the latest_simplified_eap is updated in EAPRegistration + self.eap_registration.refresh_from_db() + self.assertEqual( + self.eap_registration.latest_simplified_eap.id, + third_snapshot.id, + ) + + # NOTE: Again Transition to UNDER_REVIEW + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + data = { + "status": EAPStatus.UNDER_REVIEW, + } + + # Upload updated checklist file + # UPDATES on the second snapshot + url = f"/api/v2/simplified-eap/{third_snapshot.id}/" + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "risks_selected_protocols": "Protocol A and Protocol B.", + "selected_early_actions": "The early actions selected.", + "overall_objective_intervention": "To reduce risks through early actions.", + "eap_registration": third_snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + + response = self.client.patch(url, file_data, format="json") + self.assertEqual(response.status_code, 200) + + # SUCCESS: + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + + # NOTE: Transition to TECHNICALLY_VALIDATED + # UNDER_REVIEW -> TECHNICALLY_VALIDATED + data = { + "status": EAPStatus.TECHNICALLY_VALIDATED, + } + + # Login as NS user + # FAILS: As only ifrc admins or superuser can + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # Login as IFRC admin user + # SUCCESS: As only ifrc admins or superuser can + self.authenticate(self.ifrc_admin_user) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED) + self.eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.technically_validated_at, + ) + + # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + } + + # Uploading checklist file + # Create a temporary .xlsx file for testing + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data["review_checklist_file"] = tmp_file + + response = self.client.post(self.url, data, format="multipart") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + + # Check if four snapshots are created now + self.eap_registration.refresh_from_db() + eap_simplified_queryset = SimplifiedEAP.objects.filter( + eap_registration=self.eap_registration, + ) + self.assertEqual( + eap_simplified_queryset.count(), + 4, + "There should be four snapshots created.", + ) + + # Check version of the latest snapshot + # Version should be 4 + fourth_snapshot = eap_simplified_queryset.order_by("-version").first() + assert fourth_snapshot is not None, "fourth snapshot should not be None." + + self.assertEqual( + fourth_snapshot.version, + 4, + "Latest snapshot version should be 4.", + ) + # Check for parent_id + self.assertEqual( + fourth_snapshot.parent_id, + third_snapshot.id, + "Latest snapshot's parent_id should be the third Snapshot id.", + ) + + # Check if the second snapshot is locked. + third_snapshot.refresh_from_db() + self.assertTrue(third_snapshot.is_locked) + # Snapshot Shouldn't have the updated checklist file + self.assertFalse( + fourth_snapshot.updated_checklist_file, + "Latest snapshot shouldn't have the updated checklist file.", + ) + + # Check if the latest_simplified_eap is updated in EAPRegistration + self.eap_registration.refresh_from_db() + self.assertEqual( + self.eap_registration.latest_simplified_eap.id, + fourth_snapshot.id, + ) + + # NOTE: NS Updates the latest changes on the fourth snapshot and update checklist file + + # NOTE: Again Transition to UNDER_REVIEW + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + data = { + "status": EAPStatus.UNDER_REVIEW, + } + + # Upload updated checklist file + # UPDATES on the second snapshot + url = f"/api/v2/simplified-eap/{fourth_snapshot.id}/" + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "risks_selected_protocols": "Protocol A and Protocol B.", + "selected_early_actions": "The early actions selected.", + "overall_objective_intervention": "To reduce risks through early actions.", + "eap_registration": third_snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + + response = self.client.patch(url, file_data, format="json") + self.assertEqual(response.status_code, 200) + + # SUCCESS: + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + + # NOTE: Transition to TECHNICALLY_VALIDATED + # UNDER_REVIEW -> TECHNICALLY_VALIDATED + data = { + "status": EAPStatus.TECHNICALLY_VALIDATED, + } + + # Login as NS user + # FAILS: As only ifrc admins or superuser can + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # Login as IFRC admin user + # SUCCESS: As only ifrc admins or superuser can + self.authenticate(self.ifrc_admin_user) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED) + self.eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.technically_validated_at, + ) + + # NOTE: Transition to APPROVED + # TECHNICALLY_VALIDATED -> PENDING_PFA + data = { + "status": EAPStatus.PENDING_PFA, + } + + # LOGIN as country admin user + # FAILS: As only ifrc admins or superuser can + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # NOTE: Upload Validated budget file + url = f"/api/v2/eap-registration/{self.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(url, file_data, format="multipart") + self.assertEqual(response.status_code, 200, response.data) + + self.eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.validated_budget_file, + ) + + # LOGIN as IFRC admin user + # SUCCESS: As only ifrc admins or superuser can + self.assertIsNone(self.eap_registration.pending_pfa_at) + self.authenticate(self.ifrc_admin_user) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], EAPStatus.PENDING_PFA) + # Check is the approved timeline is added + self.eap_registration.refresh_from_db() + self.assertIsNotNone(self.eap_registration.pending_pfa_at) + + # NOTE: Check as if user cannot update after PENDING_PFA_AT + # FAILS As simplified EAP is in PENDING_PFA, cannot updated + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400) + + # NOTE: Transition to APPROVED + # PENDING_PFA -> APPROVED + data = { + "status": EAPStatus.APPROVED, + } + + # LOGIN as country admin user + # FAILS: As only ifrc admins or superuser can + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # LOGIN as IFRC admin user + # SUCCESS: As only ifrc admins or superuser can + self.assertIsNone(self.eap_registration.approved_at) + self.authenticate(self.ifrc_admin_user) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], EAPStatus.APPROVED) + # Check is the pfa_signed timeline is added + self.eap_registration.refresh_from_db() + self.assertIsNotNone(self.eap_registration.approved_at) + + # Check as if NS user cannot update after APPROVED + # FAILS As simplified EAP is in APPROVED, cannot update + self.authenticate(self.country_admin) + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400) + + # NOTE: Transition to ACTIVATED + # APPROVED -> ACTIVATED + data = { + "status": EAPStatus.ACTIVATED, + } + + # LOGIN as country admin user + # FAILS: As only ifrc admins or superuser can + self.authenticate(self.country_admin) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 400) + + # LOGIN as IFRC admin user + # SUCCESS: As only ifrc admins or superuser can + self.assertIsNone(self.eap_registration.activated_at) + self.authenticate(self.ifrc_admin_user) + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], EAPStatus.ACTIVATED) + # Check is the activated timeline is added + self.eap_registration.refresh_from_db() + self.assertIsNotNone(self.eap_registration.activated_at) + + # Check as if NS user cannot update after ACTIVATED + # FAILS As simplified EAP is in ACTIVATED, cannot updated + self.authenticate(self.country_admin) + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400) + + @mock.patch("eap.serializers.generate_export_eap_pdf") + @mock.patch("eap.serializers.group") + @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, + mock_group, + generate_export_eap_pdf, + ): + + # 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.country_admin, + modified_by=self.country_admin, + ) + 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) + generate_export_eap_pdf.delay.assert_called_once_with( + eap_registration_id=eap_registration.id, version=simplified_eap.version + ) + 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) + + # NOTE: Check that two signatures are created + mock_group.assert_called_once() + self.assertEqual(len(mock_group.call_args.args), 2) + + 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) + self.assertTrue(mock_group.called) + 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) + self.assertTrue(mock_group.called) + 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): + super().setUp() + self.country = CountryFactory.create( + name="country1", + iso3="EAP", + iso="EA", + ) + self.national_society = CountryFactory.create( + name="national_society1", + iso3="NSC", + iso="NS", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ", iso="ZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA", iso="AA") + + self.user = UserFactory.create() + self.url = "/api/v2/pdf-export/" + + @mock.patch("api.serializers.generate_export_pdf.delay") + def test_simplified_eap_export(self, mock_generate_url): + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.SIMPLIFIED_EAP, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + 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.user, + modified_by=self.user, + national_society_contact_title="NS Title Example", + budget_file=EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ), + ) + eap_registration.latest_simplified_eap = simplified_eap + eap_registration.save() + + data = { + "export_type": Export.ExportType.SIMPLIFIED_EAP, + "export_id": eap_registration.id, + "is_pga": False, + } + + self.authenticate(self.user) + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(self.url, data, format="json") + self.assert_201(response) + self.assertIsNotNone(response.data["id"], response.data) + + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/simplified/" + self.assertEqual(response.data["url"], expected_url) + self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) + + self.assertEqual(mock_generate_url.called, True) + title = f"{self.national_society.name}-{self.disaster_type.name}" + mock_generate_url.assert_called_once_with( + response.data["id"], + title, + django_get_language(), + ) + + # Test Export Snapshot + + # create a new snapshot + simplfied_eap_snapshot = simplified_eap.generate_snapshot() + assert simplfied_eap_snapshot.version == 2, "Snapshot version should be 2" + + data = { + "export_type": Export.ExportType.SIMPLIFIED_EAP, + "export_id": eap_registration.id, + "version": 2, + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(self.url, data, format="json") + self.assert_201(response) + self.assertIsNotNone(response.data["id"], response.data) + + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/simplified/?version=2" + self.assertEqual(response.data["url"], expected_url) + + @mock.patch("api.serializers.generate_export_pdf.delay") + def test_full_eap_export(self, mock_generate_url): + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.FULL_EAP, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.user, + modified_by=self.user, + ) + + full_eap = FullEAPFactory.create( + eap_registration=eap_registration, + created_by=self.user, + modified_by=self.user, + budget_file=EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ), + ) + + eap_registration.latest_full_eap = full_eap + eap_registration.save() + + data = { + "export_type": Export.ExportType.FULL_EAP, + "export_id": eap_registration.id, + "is_pga": False, + } + + self.authenticate(self.user) + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(self.url, data, format="json") + self.assert_201(response) + self.assertIsNotNone(response.data["id"], response.data) + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/full/" + self.assertEqual(response.data["url"], expected_url) + self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) + + self.assertEqual(mock_generate_url.called, True) + title = f"{self.national_society.name}-{self.disaster_type.name}" + mock_generate_url.assert_called_once_with( + response.data["id"], + title, + django_get_language(), + ) + + @mock.patch("api.serializers.generate_export_pdf.delay") + def test_diff_export_eap(self, mock_generate_url): + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.SIMPLIFIED_EAP, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.user, + modified_by=self.user, + ) + + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=eap_registration, + created_by=self.user, + modified_by=self.user, + budget_file=EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ), + ) + + eap_registration.latest_simplified_eap = simplified_eap + eap_registration.save() + + self.authenticate(self.user) + data = { + "export_type": Export.ExportType.SIMPLIFIED_EAP, + "export_id": eap_registration.id, + "diff": True, + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(self.url, data, format="json") + self.assert_201(response) + self.assertIsNotNone(response.data["id"], response.data) + + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/simplified/?diff=true" + self.assertEqual(response.data["url"], expected_url) + + self.assertEqual(mock_generate_url.called, True) + title = f"{self.national_society.name}-{self.disaster_type.name}" + mock_generate_url.assert_called_once_with( + response.data["id"], + title, + django_get_language(), + ) + + +class EAPFullTestCase(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="EAP") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="NSC", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + + # Create permissions + management.call_command("make_permissions") + + # Create Country Admin User and assign permission + 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) + + def test_list_full_eap(self): + # Create EAP Registrations + eap_registrations = EAPRegistrationFactory.create_batch( + 5, + eap_type=EAPType.FULL_EAP, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + for eap in eap_registrations: + FullEAPFactory.create( + eap_registration=eap, + created_by=self.country_admin, + modified_by=self.country_admin, + budget_file=EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ), + ) + + url = "/api/v2/full-eap/" + self.authenticate() + response = self.client.get(url) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(response.data["results"]), 5) + + def test_create_full_eap(self): + url = "/api/v2/full-eap/" + + # Create EAP Registration + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.FULL_EAP, + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + budget_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + forecast_table_file = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + image_1 = EAPFileFactory._create_image( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + image_2 = EAPFileFactory._create_image( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + data = { + "eap_registration": eap_registration.id, + "national_society_contact_name": "National society contact name", + "national_society_contact_email": "test@example.com", + "ifrc_delegation_focal_point_name": "IFRC delegation focal point name", + "ifrc_delegation_focal_point_email": "test_ifrc@example.com", + "ifrc_head_of_delegation_name": "IFRC head of delegation name", + "ifrc_head_of_delegation_email": "ifrc_head@example.com", + "early_actions": [ + { + "action": "Early action 1", + }, + { + "action": "Early action 2", + }, + ], + "prioritized_impacts": [ + { + "impact": "Prioritized impact 1", + }, + { + "impact": "Prioritized impact 2", + }, + ], + "partner_contacts": [ + { + "name": "Partner 1 Contact", + "email": "partner1@example.com", + "title": "Partner 1 Title", + "phone_number": "+1234567890", + }, + { + "name": "Partner 2 Contact", + "email": "partner2@example.com", + "title": "Partner 2 Title", + }, + ], + "budget_file": budget_file_instance.id, + "forecast_table_file": forecast_table_file.id, + "hazard_selection_images": [ + { + "id": image_1.id, + "caption": "Image 1 caption", + }, + { + "id": image_2.id, + "caption": "Image 2 caption", + }, + ], + "exposed_element_and_vulnerability_factor_images": [ + { + "id": image_1.id, + "caption": "Image 1 caption", + }, + { + "id": image_2.id, + "caption": "Image 2 caption", + }, + ], + "prioritized_impact_images": [ + { + "id": image_1.id, + }, + { + "id": image_2.id, + }, + ], + "forecast_selection_images": [ + { + "id": image_1.id, + }, + { + "id": image_2.id, + "caption": "Image caption", + }, + ], + "total_budget": 10000, + "objective": "FUll eap objective", + "lead_time": 5, + "expected_submission_time": "2024-12-31", + "readiness_budget": 3000, + "pre_positioning_budget": 4000, + "early_action_budget": 3000, + "people_targeted": 5000, + "key_actors": [ + { + "national_society": self.national_society.id, + "description": "Key actor 1 description", + }, + { + "national_society": self.country.id, + "description": "Key actor 1 description", + }, + ], + "is_worked_with_government": True, + "worked_with_government_description": "Worked with government description", + "is_technical_working_groups": True, + "technical_working_group_title": "Technical working group title", + "technical_working_groups_in_place_description": "Technical working groups in place description", + "hazard_selection": "Flood", + "exposed_element_and_vulnerability_factor": "Exposed elements and vulnerability factors", + "prioritized_impact": "Prioritized impacts", + "trigger_statement": "Triggering statement", + "forecast_selection": "Rainfall forecast", + "definition_and_justification_impact_level": "Definition and justification of impact levels", + "identification_of_the_intervention_area": "Identification of the intervention areas", + "early_action_selection_process": "Early action selection process", + "evidence_base": "Evidence base", + "usefulness_of_actions": "Usefulness of actions", + "feasibility": "Feasibility text", + "early_action_implementation_process": "Early action implementation process", + "trigger_activation_system": "Trigger activation system", + "selection_of_target_population": "Selection of target population", + "stop_mechanism": "Stop mechanism", + "meal": "meal description", + "operational_administrative_capacity": "Operational and administrative capacity", + "strategies_and_plans": "Strategies and plans", + "advance_financial_capacity": "Advance financial capacity", + # BUDGET DETAILS + "budget_description": "Budget description", + "readiness_cost_description": "Readiness cost description", + "prepositioning_cost_description": "Prepositioning cost description", + "early_action_cost_description": "Early action cost description", + "eap_endorsement": "EAP endorsement text", + "planned_operations": [ + { + "sector": PlannedOperation.Sector.SETTLEMENT_AND_HOUSING, + "ap_code": 111, + "people_targeted": 10000, + "budget_per_sector": 100000, + "indicators": [ + { + "title": "indicator 1", + "target": 100, + }, + { + "title": "indicator 2", + "target": 200, + }, + ], + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.ONE_YEAR, + YearsTimeFrameChoices.TWO_YEARS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, + ], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.FIVE_YEARS], + } + ], + } + ], + "enabling_approaches": [ + { + "ap_code": 11, + "approach": EnablingApproach.Approach.SECRETARIAT_SERVICES, + "budget_per_approach": 10000, + "indicators": [ + { + "title": "indicator enable approach 1", + "target": 300, + }, + { + "title": "indicator enable approach 2", + "target": 400, + }, + ], + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.THREE_YEARS], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": TimeFrame.YEARS, + "time_value": [ + YearsTimeFrameChoices.FIVE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, + ], + } + ], + }, + ], + } + + self.authenticate(self.country_admin) + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 201, response.data) + + self.assertEqual( + response.data["eap_registration"], + eap_registration.id, + ) + self.assertEqual( + eap_registration.get_eap_type_enum, + EAPType.FULL_EAP, + ) + self.assertFalse( + response.data["is_locked"], + "Newly created Full EAP should not be locked.", + ) + + # Check latest simplified EAP in registration + eap_registration.refresh_from_db() + self.assertEqual( + eap_registration.latest_full_eap.id, + response.data["id"], + ) + + # Cannot create Full EAP for the same EAP Registration again + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 400, response.data) + + def test_update_full_eap(self): + # Create EAP Registration + eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.FULL_EAP, + country=self.country, + status=EAPStatus.UNDER_DEVELOPMENT, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.country_admin, + modified_by=self.country_admin, + ) + + full_eap = FullEAPFactory.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, + ), + ) + + url = f"/api/v2/full-eap/{full_eap.id}/" + data = { + "total_budget": 20000, + "seap_timeframe": 5, + "key_actors": [ + { + "national_society": self.national_society.id, + "description": "Key actor 1 description", + }, + { + "national_society": self.country.id, + "description": "Key actor 1 description", + }, + ], + } + self.authenticate(self.root_user) + response = self.client.patch(url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + + self.assertIsNotNone(response.data["modified_by_details"]) + self.assertEqual( + { + response.data["total_budget"], + response.data["modified_by_details"]["id"], + }, + { + data["total_budget"], + self.root_user.id, + }, + ) + + +class TestSnapshotEAP(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="EAP") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="NSC", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.user = UserFactory.create() + self.registration = EAPRegistrationFactory.create( + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + created_by=self.user, + modified_by=self.user, + ) + + def test_snapshot_full_eap(self): + # Create M2M objects + enabling_approach = EnablingApproachFactory.create( + approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, + budget_per_approach=5000, + ap_code=123, + ) + hazard_selection_image_1 = EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ) + hazard_selection_image_2 = EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ) + key_actor_1 = KeyActorFactory.create( + national_society=self.national_society, + description="Key actor 1 description", + ) + + key_actor_2 = KeyActorFactory.create( + national_society=self.country, + description="Key actor 1 description", + ) + + planned_operation = PlannedOperationFactory.create( + sector=PlannedOperation.Sector.SHELTER, + ap_code=456, + people_targeted=5000, + budget_per_sector=50000, + readiness_activities=[ + OperationActivityFactory.create( + activity="Activity 1", + timeframe=TimeFrame.MONTHS, + time_value=[MonthsTimeFrameChoices.ONE_MONTH, MonthsTimeFrameChoices.FOUR_MONTHS], + ).id, + ], + prepositioning_activities=[ + OperationActivityFactory.create( + activity="Activity 2", + timeframe=TimeFrame.MONTHS, + time_value=[MonthsTimeFrameChoices.TWO_MONTHS], + ).id, + ], + ) + + # Base instance + original = FullEAPFactory.create( + eap_registration=self.registration, + total_budget=5000, + budget_file=EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ), + created_by=self.user, + modified_by=self.user, + ) + original.key_actors.add(key_actor_1, key_actor_2) + original.enabling_approaches.add(enabling_approach) + original.planned_operations.add(planned_operation) + original.hazard_selection_images.add(hazard_selection_image_1, hazard_selection_image_2) + + # Generate snapshot + snapshot = original.generate_snapshot() + + # PK changed + self.assertNotEqual(snapshot.pk, original.pk) + + # Check version + self.assertEqual(snapshot.version, original.version + 1) + + # Fields copied + self.assertEqual( + { + snapshot.total_budget, + snapshot.eap_registration, + snapshot.created_by, + snapshot.modified_by, + snapshot.budget_file, + }, + { + original.total_budget, + original.eap_registration, + original.created_by, + original.modified_by, + original.budget_file, + }, + ) + + # M2M deeply cloned on approach + orig_approaches = list(original.enabling_approaches.all()) + snapshot_approaches = list(snapshot.enabling_approaches.all()) + self.assertEqual(len(orig_approaches), len(snapshot_approaches)) + + self.assertNotEqual(orig_approaches[0].pk, snapshot) + + # M2M planned operations deeply cloned + orig_operations = list(original.planned_operations.all()) + snapshot_operations = list(snapshot.planned_operations.all()) + self.assertEqual(len(orig_operations), len(snapshot_operations)) + self.assertNotEqual(orig_operations[0].pk, snapshot_operations[0].pk) + + self.assertEqual( + orig_operations[0].sector, + snapshot_operations[0].sector, + ) + + # M2M operation activities deeply cloned + orig_readiness_activities = list(orig_operations[0].readiness_activities.all()) + snapshot_readiness_activities = list(snapshot_operations[0].readiness_activities.all()) + self.assertEqual(len(orig_readiness_activities), len(snapshot_readiness_activities)) + + self.assertNotEqual( + orig_readiness_activities[0].pk, + snapshot_readiness_activities[0].pk, + ) + self.assertEqual( + orig_readiness_activities[0].activity, + snapshot_readiness_activities[0].activity, + ) + + # M2M hazard selection images copied + orig_hazard_images = list(original.hazard_selection_images.all()) + snapshot_hazard_images = list(snapshot.hazard_selection_images.all()) + self.assertEqual(len(orig_hazard_images), len(snapshot_hazard_images)) + self.assertEqual( + orig_hazard_images[0].pk, + snapshot_hazard_images[0].pk, + ) + # M2M Actors clone but not the national society FK + orig_actors = list(original.key_actors.all()) + snapshot_actors = list(snapshot.key_actors.all()) + self.assertEqual(len(orig_actors), len(snapshot_actors)) + self.assertNotEqual(orig_actors[0].pk, snapshot_actors[0].pk) + self.assertEqual( + orig_actors[0].national_society, + snapshot_actors[0].national_society, + ) + self.assertEqual( + orig_actors[0].description, + snapshot_actors[0].description, + ) + + # Assert previous_id for all M2M objects + for orig, snap in zip(original.key_actors.all(), snapshot.key_actors.all()): + self.assertEqual(snap.previous_id, orig.pk) + + for orig, snap in zip(original.enabling_approaches.all(), snapshot.enabling_approaches.all()): + self.assertEqual(snap.previous_id, orig.pk) + + for orig_op, snap_op in zip(original.planned_operations.all(), snapshot.planned_operations.all()): + self.assertEqual(snap_op.previous_id, orig_op.pk) + for orig_act, snap_act in zip(orig_op.readiness_activities.all(), snap_op.readiness_activities.all()): + self.assertEqual(snap_act.previous_id, orig_act.pk) + + +class EAPGlobalFileTestCase(APITestCase): + def setUp(self): + super().setUp() + self.url = "/api/v2/eap/global-files/" + + def test_get_template_files_invalid_param(self): + self.authenticate() + response = self.client.get(f"{self.url}invalid_type/") + self.assert_400(response) + self.assertIn("detail", response.data) + + def test_get_budget_template(self): + self.authenticate() + response = self.client.get(f"{self.url}budget_template/") + self.assert_200(response) + self.assertIn("url", response.data) + self.assertTrue(response.data["url"].endswith("files/eap/budget_template.xlsm")) + + def test_get_forecast_table_template(self): + self.authenticate() + response = self.client.get(f"{self.url}forecast_table/") + self.assert_200(response) + self.assertIn("url", response.data) + self.assertTrue(response.data["url"].endswith("files/eap/forecasts_table.docx")) + + def test_get_theory_of_change_template(self): + self.authenticate() + response = self.client.get(f"{self.url}theory_of_change_table/") + self.assert_200(response) + self.assertIn("url", response.data) + self.assertTrue(response.data["url"].endswith("files/eap/theory_of_change_table.docx")) + + def test_get_template_files_unauthenticated(self): + response = self.client.get(f"{self.url}budget_template/") + self.assert_401(response) diff --git a/eap/tests.py b/eap/tests.py index a39b155ac..e69de29bb 100644 --- a/eap/tests.py +++ b/eap/tests.py @@ -1 +0,0 @@ -# Create your tests here. diff --git a/eap/utils.py b/eap/utils.py new file mode 100644 index 000000000..04f25d5fa --- /dev/null +++ b/eap/utils.py @@ -0,0 +1,244 @@ +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.""" + country_admin_ids = [ + int(codename.replace("country_admin_", "")) + for codename in Permission.objects.filter( + group__user=user, + codename__startswith="country_admin_", + ).values_list("codename", flat=True) + ] + + return country_id in country_admin_ids + + +def is_user_ifrc_admin(user: User) -> bool: + """ + Checks if the user has IFRC Admin or superuser permissions. + + Returns True if the user is a superuser or has the IFRC Admin permission, False otherwise. + """ + + if user.is_superuser or user.has_perm("api.ifrc_admin"): + return True + return False + + +def validate_file_extention(filename: str, allowed_extensions: list[str]): + """ + This function validates a file's extension against a list of allowed extensions. + Args: + filename: The name of the file to validate. + Returns: + ValidationError: If the file extension is not allowed. + """ + + extension = os.path.splitext(filename)[1].replace(".", "") + if extension.lower() not in allowed_extensions: + raise ValidationError(f"Invalid uploaded file extension: {extension}, Supported only {allowed_extensions} Files") + + +T = TypeVar("T", bound=models.Model) + + +def copy_model_instance( + instance: T, + overrides: Dict[str, Any] | None = None, + exclude_clone_m2m_fields: Set[str] | None = None, + clone_cache: Dict[tuple[type[T], int], T] | None = None, +) -> T: + """ + Recursively clone a Django model instance, including nested M2M fields. + Uses clone_cache to prevent infinite loops and duplicated clones. + Args: + instance: The Django model instance to clone. + overrides: A dictionary of field names and values to override in the cloned instance. + exclude_clone_m2m_fields: A set of M2M field names to exclude from cloning ( + these will link to existing related objects instead). + clone_cache: A dictionary to keep track of already cloned instances to prevent infinite loops. + Returns: + The cloned Django model instance. + + """ + + overrides = overrides or {} + exclude_m2m = exclude_clone_m2m_fields or set() + clone_cache = clone_cache or {} + + key = (instance.__class__, instance.pk) + + # already cloned -> return that clone + if key in clone_cache: + return clone_cache[key] + + opts = instance._meta + data: Dict[str, Any] = {} + + # Cloning standard fields + for field in opts.fields: + if field.auto_created: + continue + data[field.name] = getattr(instance, field.name) + + data[opts.pk.attname] = None + + data.update(overrides) + + clone: T = instance.__class__.objects.create(**data) + # NOTE: Register the clone in cache before cloning M2M to handle circular references + clone_cache[key] = clone + + for m2m_field in opts.many_to_many: + name = m2m_field.name + + # excluded M2M: only link to existing related objects + if name in exclude_m2m: + related = getattr(instance, name).all() + getattr(clone, name).set(related) + continue + + related = getattr(instance, name).all() + cloned_related: list[T] = [] + + for obj in related: + overrides_obj = {} + if hasattr(obj, "previous_id"): + overrides_obj["previous_id"] = obj.pk + + cloned_obj = copy_model_instance( + obj, + overrides=overrides_obj, + exclude_clone_m2m_fields=exclude_m2m, + clone_cache=clone_cache, + ) + cloned_related.append(cloned_obj) + + getattr(clone, name).set(cloned_related) + + return clone diff --git a/eap/views.py b/eap/views.py index 60f00ef0e..96e13ba1a 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1 +1,396 @@ # Create your views here. +from django.db.models import Case, F, IntegerField, When +from django.db.models.query import Prefetch, QuerySet +from django.templatetags.static import static +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import mixins, permissions, response, status, viewsets +from rest_framework.decorators import action + +from eap.filter_set import ( + EAPRegistrationFilterSet, + FullEAPFilterSet, + SimplifiedEAPFilterSet, +) +from eap.models import ( + EAPFile, + EAPRegistration, + EAPStatus, + EAPType, + EnablingApproach, + FullEAP, + KeyActor, + PlannedOperation, + SimplifiedEAP, +) +from eap.permissions import ( + EAPBasePermission, + EAPRegistrationPermissions, + EAPValidatedBudgetPermission, +) +from eap.serializers import ( + EAPFileInputSerializer, + EAPFileSerializer, + EAPGlobalFilesSerializer, + EAPRegistrationSerializer, + EAPStatusSerializer, + EAPValidatedBudgetFileSerializer, + FullEAPSerializer, + MiniEAPSerializer, + SimplifiedEAPSerializer, +) +from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission + + +class EAPModelViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, +): + pass + + +class ActiveEAPViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + queryset = EAPRegistration.objects.all() + lookup_field = "id" + serializer_class = MiniEAPSerializer + permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] + filterset_class = EAPRegistrationFilterSet + + def get_queryset(self) -> QuerySet[EAPRegistration]: + return ( + super() + .get_queryset() + .filter(status__in=[EAPStatus.APPROVED, EAPStatus.ACTIVATED]) + .select_related("disaster_type", "country") + .annotate( + requirement_cost=Case( + When( + eap_type=EAPType.SIMPLIFIED_EAP, + then=F("latest_simplified_eap__total_budget"), + ), + When( + eap_type=EAPType.FULL_EAP, + then=F("latest_full_eap__total_budget"), + ), + output_field=IntegerField(), + ) + ) + ) + + +class EAPRegistrationViewSet(EAPModelViewSet): + queryset = EAPRegistration.objects.all() + lookup_field = "id" + serializer_class = EAPRegistrationSerializer + permission_classes = [ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPRegistrationPermissions, + ] + filterset_class = EAPRegistrationFilterSet + + def get_queryset(self) -> QuerySet[EAPRegistration]: + return ( + super() + .get_queryset() + .select_related( + "created_by", + "modified_by", + "national_society", + "disaster_type", + "country", + ) + .prefetch_related( + "partners", + Prefetch( + "simplified_eaps", + queryset=SimplifiedEAP.objects.select_related( + "budget_file__created_by", + "budget_file__modified_by", + "updated_checklist_file__created_by", + "updated_checklist_file__modified_by", + ), + ), + Prefetch( + "full_eaps", + queryset=FullEAP.objects.select_related( + "budget_file__created_by", + "budget_file__modified_by", + ), + ), + ) + ) + + @action( + detail=True, + url_path="status", + methods=["post"], + serializer_class=EAPStatusSerializer, + permission_classes=[permissions.IsAuthenticated, DenyGuestUserPermission], + ) + def update_status( + self, + request, + id: int, + ): + eap_registration = self.get_object() + serializer = self.get_serializer( + eap_registration, + data=request.data, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(serializer.data) + + @action( + detail=True, + url_path="upload-validated-budget-file", + methods=["post"], + serializer_class=EAPValidatedBudgetFileSerializer, + permission_classes=[ + permissions.IsAuthenticated, + DenyGuestUserPermission, + EAPValidatedBudgetPermission, + ], + ) + def upload_validated_budget_file( + self, + request, + id: int, + ): + eap_registration = self.get_object() + serializer = self.get_serializer( + eap_registration, + data=request.data, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(serializer.data) + + +class SimplifiedEAPViewSet(EAPModelViewSet): + queryset = SimplifiedEAP.objects.all() + lookup_field = "id" + serializer_class = SimplifiedEAPSerializer + filterset_class = SimplifiedEAPFilterSet + permission_classes = [ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPBasePermission, + ] + + def get_queryset(self) -> QuerySet[SimplifiedEAP]: + return ( + super() + .get_queryset() + .select_related( + "created_by", + "modified_by", + "cover_image__created_by", + "cover_image__modified_by", + "budget_file__created_by", + "budget_file__modified_by", + "eap_registration__country", + "eap_registration__disaster_type", + ) + .prefetch_related( + "eap_registration__partners", + "partner_contacts", + "admin2", + Prefetch( + "planned_operations", + queryset=PlannedOperation.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), + Prefetch( + "enabling_approaches", + queryset=EnablingApproach.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), + Prefetch( + "hazard_impact_images", + queryset=EAPFile.objects.select_related("created_by", "modified_by"), + ), + Prefetch( + "risk_selected_protocols_images", + queryset=EAPFile.objects.select_related("created_by", "modified_by"), + ), + Prefetch( + "selected_early_actions_images", + queryset=EAPFile.objects.select_related("created_by", "modified_by"), + ), + ) + ) + + +class FullEAPViewSet(EAPModelViewSet): + queryset = FullEAP.objects.all() + lookup_field = "id" + serializer_class = FullEAPSerializer + filterset_class = FullEAPFilterSet + permission_classes = [ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPBasePermission, + ] + + def get_queryset(self) -> QuerySet[FullEAP]: + return ( + super() + .get_queryset() + .select_related( + "created_by", + "modified_by", + "budget_file", + ) + .prefetch_related( + "admin2", + "partner_contacts", + "prioritized_impacts", + "early_actions", + # source information + "risk_analysis_source_of_information", + "trigger_statement_source_of_information", + "trigger_model_source_of_information", + "evidence_base_source_of_information", + "activation_process_source_of_information", + # Files + "hazard_selection_images", + "theory_of_change_table_file", + "exposed_element_and_vulnerability_factor_images", + "prioritized_impact_images", + "risk_analysis_relevant_files", + "forecast_selection_images", + "definition_and_justification_impact_level_images", + "identification_of_the_intervention_area_images", + "trigger_model_relevant_files", + "early_action_selection_process_images", + "evidence_base_relevant_files", + "early_action_implementation_images", + "trigger_activation_system_images", + "activation_process_relevant_files", + "meal_relevant_files", + "capacity_relevant_files", + "forecast_table_file", + Prefetch( + "key_actors", + queryset=KeyActor.objects.select_related("national_society"), + ), + Prefetch( + "planned_operations", + queryset=PlannedOperation.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), + Prefetch( + "enabling_approaches", + queryset=EnablingApproach.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), + ) + ) + + +class EAPFileViewSet( + viewsets.GenericViewSet, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, +): + queryset = EAPFile.objects.all() + lookup_field = "id" + permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] + serializer_class = EAPFileSerializer + + def get_queryset(self) -> QuerySet[EAPFile]: + if self.request is None: + return EAPFile.objects.none() + return EAPFile.objects.filter( + created_by=self.request.user, + ).select_related( + "created_by", + "modified_by", + ) + + @extend_schema(request=EAPFileInputSerializer, responses=EAPFileSerializer(many=True)) + @action( + detail=False, + url_path="multiple", + methods=["POST"], + permission_classes=[permissions.IsAuthenticated, DenyGuestUserPermission], + ) + def multiple_file(self, request): + files = [files[0] for files in dict((request.data).lists()).values()] + data = [{"file": file} for file in files] + file_serializer = EAPFileSerializer(data=data, context={"request": request}, many=True) + if file_serializer.is_valid(raise_exception=True): + file_serializer.save() + return response.Response(file_serializer.data, status=status.HTTP_201_CREATED) + return response.Response(file_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class EAPGlobalFilesViewSet( + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + + serializer_class = EAPGlobalFilesSerializer + permission_classes = permissions.IsAuthenticated, DenyGuestUserPermission + + lookup_field = "template_type" + lookup_url_kwarg = "template_type" + + template_map = { + "budget_template": "files/eap/budget_template.xlsm", + "forecast_table": "files/eap/forecasts_table.docx", + "theory_of_change_table": "files/eap/theory_of_change_table.docx", + } + + @extend_schema( + request=None, + responses=EAPGlobalFilesSerializer, + parameters=[ + OpenApiParameter( + name="template_type", + location=OpenApiParameter.PATH, + description="Type of EAP template to download", + required=True, + type=str, + enum=list(template_map.keys()), + ) + ], + ) + def retrieve(self, request, *args, **kwargs): + template_type = kwargs.get("template_type") + if not template_type: + return response.Response( + { + "detail": "Template file type not found.", + }, + status=400, + ) + if template_type not in self.template_map: + return response.Response( + { + "detail": f"Invalid template file type '{template_type}'.Please use one of the following values:{(self.template_map.keys())}." # noqa + }, + status=400, + ) + serializer = EAPGlobalFilesSerializer({"url": request.build_absolute_uri(static(self.template_map[template_type]))}) + return response.Response(serializer.data) diff --git a/go-static/files/eap/budget_template.xlsm b/go-static/files/eap/budget_template.xlsm new file mode 100644 index 000000000..99eacf92b Binary files /dev/null and b/go-static/files/eap/budget_template.xlsm differ diff --git a/go-static/files/eap/forecasts_table.docx b/go-static/files/eap/forecasts_table.docx new file mode 100644 index 000000000..563c49a0c Binary files /dev/null and b/go-static/files/eap/forecasts_table.docx differ diff --git a/go-static/files/eap/theory_of_change_table.docx b/go-static/files/eap/theory_of_change_table.docx new file mode 100644 index 000000000..fd249488c Binary files /dev/null and b/go-static/files/eap/theory_of_change_table.docx differ diff --git a/main/enums.py b/main/enums.py index 9b7ced7ea..c2d5786b5 100644 --- a/main/enums.py +++ b/main/enums.py @@ -5,6 +5,7 @@ from databank import enums as databank_enums from deployments import enums as deployments_enums from dref import enums as dref_enums +from eap import enums as eap_enums from flash_update import enums as flash_update_enums from local_units import enums as local_units_enums from notifications import enums as notifications_enums @@ -19,6 +20,7 @@ ("notifications", notifications_enums.enum_register), ("databank", databank_enums.enum_register), ("local_units", local_units_enums.enum_register), + ("eap", eap_enums.enum_register), ] 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..3a685bc86 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, ""), + EMAIL_EAP_DREF_AA_GLOBAL_TEAM=(list, []), + EMAIL_EAP_AFRICA_COORDINATORS=(list, []), + EMAIL_EAP_AMERICAS_COORDINATORS=(list, []), + EMAIL_EAP_ASIA_PACIFIC_COORDINATORS=(list, []), + EMAIL_EAP_EUROPE_COORDINATORS=(list, []), + EMAIL_EAP_MENA_COORDINATORS=(list, []), # 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 f4a31cb42..59bb57bf2 100644 --- a/main/urls.py +++ b/main/urls.py @@ -56,6 +56,8 @@ from databank.views import CountryOverviewViewSet 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 @@ -192,6 +194,14 @@ # Databank router.register(r"country-income", data_bank_views.FDRSIncomeViewSet, basename="country_income") +# EAP(Early Action Protocol) +router.register(r"active-eap", eap_views.ActiveEAPViewSet, basename="active_eap") +router.register(r"eap-registration", eap_views.EAPRegistrationViewSet, basename="development_registration_eap") +router.register(r"simplified-eap", eap_views.SimplifiedEAPViewSet, basename="simplified_eap") +router.register(r"full-eap", eap_views.FullEAPViewSet, basename="full_eap") +router.register(r"eap-file", eap_views.EAPFileViewSet, basename="eap_file") +router.register(r"eap/global-files", eap_views.EAPGlobalFilesViewSet, basename="eap_global_files") + admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin" @@ -279,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