From 91b406ab64dc0968d69b972637b938f28bb858c3 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 4 Nov 2025 14:25:55 +0545 Subject: [PATCH 01/57] feat(eap): Add DevelopmentRegistration EAP model --- .../0003_developmentregistrationeap.py | 49 ++++++++ eap/models.py | 116 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 eap/migrations/0003_developmentregistrationeap.py diff --git a/eap/migrations/0003_developmentregistrationeap.py b/eap/migrations/0003_developmentregistrationeap.py new file mode 100644 index 000000000..e4d511e2f --- /dev/null +++ b/eap/migrations/0003_developmentregistrationeap.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.19 on 2025-11-04 07:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0226_nsdinitiativescategory_and_more'), + ('eap', '0002_auto_20220708_0747'), + ] + + operations = [ + migrations.CreateModel( + name='DevelopmentRegistrationEAP', + 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(choices=[(10, 'Full application'), (20, 'Simplified application'), (30, 'Not sure')], help_text='Select the type of EAP.', verbose_name='EAP Type')), + ('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')), + ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), + ('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(null=True, 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')), + ('modified_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified_by', to=settings.AUTH_USER_MODEL, verbose_name='modified by')), + ('national_society', 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)')), + ('partners', 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')), + ], + options={ + 'verbose_name': 'Development Registration EAP', + 'verbose_name_plural': 'Development Registration EAPs', + }, + ), + ] diff --git a/eap/models.py b/eap/models.py index dce1df67b..f626416c7 100644 --- a/eap/models.py +++ b/eap/models.py @@ -174,3 +174,119 @@ class Meta: def __str__(self): return f"{self.id}" + + +# --- Early Action Protocol --- ## + + +class EAPType(models.IntegerChoices): + Full_application = 10, _("Full application") + Simplified_application = 20, _("Simplified application") + Not_sure = 30, _("Not sure") + + +class DevelopmentRegistrationEAP(models.Model): + 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, + null=True, + related_name="%(class)s_created_by", + ) + modified_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("modified by"), + on_delete=models.SET_NULL, + null=True, + related_name="%(class)s_modified_by", + ) + 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_type = models.ForeignKey( + 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."), + ) + 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, + ) + + # Contacts + # National Society + national_society_contact_name = models.CharField( + verbose_name=_("national society contact name"), max_length=255, null=True, blank=True + ) + 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, null=True, blank=True + ) + 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 + ) + + class Meta: + verbose_name = _("Development Registration EAP") + verbose_name_plural = _("Development Registration EAPs") + + 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()}" From 5a90fa48b2c738e211800258de4f5b7fcfd28446 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 4 Nov 2025 14:27:14 +0545 Subject: [PATCH 02/57] feat(eap): Add DevelopmentRegistrationEAP Endpoint - Add Serializer, filterset - Admin setup, router for eap development-registration --- eap/admin.py | 44 +++++++++++++++++++++++++++++++++++++++++++- eap/filter_set.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ eap/serializers.py | 25 +++++++++++++++++++++++++ eap/views.py | 28 ++++++++++++++++++++++++++++ main/urls.py | 6 ++++++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 eap/filter_set.py create mode 100644 eap/serializers.py diff --git a/eap/admin.py b/eap/admin.py index 846f6b406..da7d23b9a 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1 +1,43 @@ -# Register your models here. +from django.contrib import admin + +from eap.models import DevelopmentRegistrationEAP + + +@admin.register(DevelopmentRegistrationEAP) +class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): + list_select_related = True + search_fields = ( + "national_society__name", + "country__name", + "disaster_type__name", + ) + list_filter = ("eap_type", "disaster_type", "national_society") + list_display = ( + "national_society", + "country", + "eap_type", + "disaster_type", + ) + autocomplete_fields = ( + "national_society", + "disaster_type", + "partners", + "created_by", + "modified_by", + ) + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + "national_society", + "country", + "disaster_type", + "created_by", + "modified_by", + ) + .prefetch_related( + "partners", + ) + ) diff --git a/eap/filter_set.py b/eap/filter_set.py new file mode 100644 index 000000000..9440b5ced --- /dev/null +++ b/eap/filter_set.py @@ -0,0 +1,44 @@ +import django_filters as filters + +from eap.models import DevelopmentRegistrationEAP, EAPType +from api.models import Country, DisasterType + + +class BaseEAPFilterSet(filters.FilterSet): + 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"] + ) + # 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 DevelopmentRegistrationEAPFilterSet(BaseEAPFilterSet): + eap_type = filters.ChoiceFilter( + choices=EAPType.choices, + label="EAP Type", + ) + + class Meta: + model = DevelopmentRegistrationEAP + fields = () diff --git a/eap/serializers.py b/eap/serializers.py new file mode 100644 index 000000000..6108b7225 --- /dev/null +++ b/eap/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from api.serializers import MiniCountrySerializer, UserNameSerializer +from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin +from eap.models import DevelopmentRegistrationEAP + + +class DevelopmentRegistrationEAPSerializer( + NestedUpdateMixin, + NestedCreateMixin, + serializers.ModelSerializer, +): + 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) + + # User details + created_by_details = UserNameSerializer(source="created_by", read_only=True) + modified_by_details = UserNameSerializer(source="modified_by", read_only=True) + + class Meta: + model = DevelopmentRegistrationEAP + fields = "__all__" diff --git a/eap/views.py b/eap/views.py index 60f00ef0e..0316f5bbc 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1 +1,29 @@ # Create your views here. +from rest_framework import permissions, viewsets + +from eap.filter_set import DevelopmentRegistrationEAPFilterSet +from eap.models import DevelopmentRegistrationEAP +from eap.serializers import DevelopmentRegistrationEAPSerializer +from main.permissions import DenyGuestUserMutationPermission + + +class DevelopmentRegistrationEAPViewset(viewsets.ModelViewSet): + queryset = DevelopmentRegistrationEAP.objects.all() + serializer_class = DevelopmentRegistrationEAPSerializer + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] + filterset_class = DevelopmentRegistrationEAPFilterSet + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related( + "created_by", + "modified_by", + "national_society", + "disaster_type", + ) + .prefetch_related( + "partners", + ) + ) diff --git a/main/urls.py b/main/urls.py index 0733965ad..2320ac52c 100644 --- a/main/urls.py +++ b/main/urls.py @@ -56,6 +56,7 @@ 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 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 +193,11 @@ # Databank router.register(r"country-income", data_bank_views.FDRSIncomeViewSet, basename="country_income") +# EAP(Early Action Protocol) +router.register( + r"development-registration-eap", eap_views.DevelopmentRegistrationEAPViewset, basename="development_registration_eap" +) + admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin" From a75ab93ade6c863b76d3fbb750df57547de319c6 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 13:44:03 +0545 Subject: [PATCH 03/57] feat(eap): Add EAP type and status for EAP Registration --- eap/admin.py | 4 +- eap/enums.py | 6 ++ eap/filter_set.py | 14 ++-- ...strationeap.py => 0003_eapregistration.py} | 8 ++- eap/models.py | 65 +++++++++++++++++-- eap/serializers.py | 13 +++- eap/views.py | 14 ++-- main/urls.py | 4 +- 8 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 eap/enums.py rename eap/migrations/{0003_developmentregistrationeap.py => 0003_eapregistration.py} (85%) diff --git a/eap/admin.py b/eap/admin.py index da7d23b9a..80c13058b 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1,9 +1,9 @@ from django.contrib import admin -from eap.models import DevelopmentRegistrationEAP +from eap.models import EAPRegistration -@admin.register(DevelopmentRegistrationEAP) +@admin.register(EAPRegistration) class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): list_select_related = True search_fields = ( diff --git a/eap/enums.py b/eap/enums.py new file mode 100644 index 000000000..2db8a262d --- /dev/null +++ b/eap/enums.py @@ -0,0 +1,6 @@ +from . import models + +enum_register = { + "eap_status": models.EAPStatus, + "eap_type": models.EAPType, +} diff --git a/eap/filter_set.py b/eap/filter_set.py index 9440b5ced..37fc46fc8 100644 --- a/eap/filter_set.py +++ b/eap/filter_set.py @@ -1,16 +1,12 @@ import django_filters as filters -from eap.models import DevelopmentRegistrationEAP, EAPType from api.models import Country, DisasterType +from eap.models import EAPRegistration, EAPType class BaseEAPFilterSet(filters.FilterSet): - 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"] - ) + 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"]) # Country country = filters.ModelMultipleChoiceFilter( field_name="country", @@ -33,12 +29,12 @@ class BaseEAPFilterSet(filters.FilterSet): ) -class DevelopmentRegistrationEAPFilterSet(BaseEAPFilterSet): +class EAPRegistrationFilterSet(BaseEAPFilterSet): eap_type = filters.ChoiceFilter( choices=EAPType.choices, label="EAP Type", ) class Meta: - model = DevelopmentRegistrationEAP + model = EAPRegistration fields = () diff --git a/eap/migrations/0003_developmentregistrationeap.py b/eap/migrations/0003_eapregistration.py similarity index 85% rename from eap/migrations/0003_developmentregistrationeap.py rename to eap/migrations/0003_eapregistration.py index e4d511e2f..7b56ad7a4 100644 --- a/eap/migrations/0003_developmentregistrationeap.py +++ b/eap/migrations/0003_eapregistration.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-11-04 07:03 +# Generated by Django 4.2.19 on 2025-11-05 07:49 from django.conf import settings from django.db import migrations, models @@ -15,12 +15,14 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='DevelopmentRegistrationEAP', + 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(choices=[(10, 'Full application'), (20, 'Simplified application'), (30, 'Not sure')], help_text='Select the type of EAP.', verbose_name='EAP Type')), + ('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, 'Approved'), (60, 'PFA Signed')], default=10, help_text='Select the current status of the EAP development process.', verbose_name='EAP Status')), + ('is_active', models.BooleanField(default=False, help_text='Indicates whether this EAP development registration is active.', verbose_name='Is Active')), ('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')), ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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')), diff --git a/eap/models.py b/eap/models.py index f626416c7..00fbe1db3 100644 --- a/eap/models.py +++ b/eap/models.py @@ -180,12 +180,41 @@ def __str__(self): class EAPType(models.IntegerChoices): - Full_application = 10, _("Full application") - Simplified_application = 20, _("Simplified application") - Not_sure = 30, _("Not sure") + FULL_EAP = 10, _("Full EAP") + SIMPLIFIED_EAP = 20, _("Simplified EAP") -class DevelopmentRegistrationEAP(models.Model): +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. + """ + + APPROVED = 50, _("Approved") + """IFRC has to upload validated budget file. + Cannot be changed back to previous statuses. + """ + + PFA_SIGNED = 60, _("PFA Signed") + """EAP should be APPROVED before changing to this status.""" + + +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, @@ -209,6 +238,8 @@ class DevelopmentRegistrationEAP(models.Model): null=True, related_name="%(class)s_modified_by", ) + + # National Society national_society = models.ForeignKey( Country, on_delete=models.CASCADE, @@ -223,17 +254,43 @@ class DevelopmentRegistrationEAP(models.Model): 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, verbose_name=("Disaster Type"), on_delete=models.PROTECT, help_text=_("Select the disaster type for which the EAP is needed"), ) + + class Meta: + abstract = True + + +# BASE MODEL FOR EAP +class EAPRegistration(EAPBaseModel): + """Model representing the EAP Development Registration.""" + 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."), ) + # TODO(susilnem): Verify this field? + is_active = models.BooleanField( + verbose_name=_("Is Active"), + help_text=_("Indicates whether this EAP development registration is active."), + default=False, + ) + expected_submission_time = models.DateField( verbose_name=_("Expected submission time"), help_text=_( diff --git a/eap/serializers.py b/eap/serializers.py index 6108b7225..3e9cba39e 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,11 +1,11 @@ from rest_framework import serializers from api.serializers import MiniCountrySerializer, UserNameSerializer +from eap.models import EAPRegistration from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin -from eap.models import DevelopmentRegistrationEAP -class DevelopmentRegistrationEAPSerializer( +class EAPRegistrationSerializer( NestedUpdateMixin, NestedCreateMixin, serializers.ModelSerializer, @@ -20,6 +20,13 @@ class DevelopmentRegistrationEAPSerializer( created_by_details = UserNameSerializer(source="created_by", read_only=True) modified_by_details = UserNameSerializer(source="modified_by", read_only=True) + # Status + status_display = serializers.CharField(source="get_status_display", read_only=True) + class Meta: - model = DevelopmentRegistrationEAP + model = EAPRegistration fields = "__all__" + read_only_fields = [ + "status", + "modified_at", + ] diff --git a/eap/views.py b/eap/views.py index 0316f5bbc..24a2eeff7 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,17 +1,17 @@ # Create your views here. from rest_framework import permissions, viewsets -from eap.filter_set import DevelopmentRegistrationEAPFilterSet -from eap.models import DevelopmentRegistrationEAP -from eap.serializers import DevelopmentRegistrationEAPSerializer +from eap.filter_set import EAPRegistrationFilterSet +from eap.models import EAPRegistration +from eap.serializers import EAPRegistrationSerializer from main.permissions import DenyGuestUserMutationPermission -class DevelopmentRegistrationEAPViewset(viewsets.ModelViewSet): - queryset = DevelopmentRegistrationEAP.objects.all() - serializer_class = DevelopmentRegistrationEAPSerializer +class EAPRegistrationViewset(viewsets.ModelViewSet): + queryset = EAPRegistration.objects.all() + serializer_class = EAPRegistrationSerializer permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] - filterset_class = DevelopmentRegistrationEAPFilterSet + filterset_class = EAPRegistrationFilterSet def get_queryset(self): return ( diff --git a/main/urls.py b/main/urls.py index 2320ac52c..911029130 100644 --- a/main/urls.py +++ b/main/urls.py @@ -194,9 +194,7 @@ router.register(r"country-income", data_bank_views.FDRSIncomeViewSet, basename="country_income") # EAP(Early Action Protocol) -router.register( - r"development-registration-eap", eap_views.DevelopmentRegistrationEAPViewset, basename="development_registration_eap" -) +router.register(r"eap-registration", eap_views.EAPRegistrationViewset, basename="development_registration_eap") admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin" From 0335580c0137d42ea46275c727b47af151aa1505 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 13:55:17 +0545 Subject: [PATCH 04/57] chore(eap): Remove disaster type and national society filters from admin --- eap/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eap/admin.py b/eap/admin.py index 80c13058b..8b5a4b1c6 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -11,7 +11,7 @@ class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): "country__name", "disaster_type__name", ) - list_filter = ("eap_type", "disaster_type", "national_society") + list_filter = ("eap_type",) list_display = ( "national_society", "country", From fde9935d5884444626a6ac288c6f4ed37ac376fd Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 16:05:06 +0545 Subject: [PATCH 05/57] chore(eap): Add eap enums in global enums --- main/enums.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/enums.py b/main/enums.py index 9b7ced7ea..176864317 100644 --- a/main/enums.py +++ b/main/enums.py @@ -9,6 +9,7 @@ from local_units import enums as local_units_enums from notifications import enums as notifications_enums from per import enums as per_enums +from eap import enums as eap_enums apps_enum_register = [ ("dref", dref_enums.enum_register), @@ -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) ] From e47198dee4e79d73d0bbf3efb3f04805cd1b2677 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 17:26:12 +0545 Subject: [PATCH 06/57] feat(eap): Add Simplified EAP model --- eap/models.py | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/eap/models.py b/eap/models.py index 00fbe1db3..b265196ea 100644 --- a/eap/models.py +++ b/eap/models.py @@ -347,3 +347,222 @@ class Meta: 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()}" + + +class SimplifiedEAP(models.Model): + """Model representing a Simplified EAP.""" + + eap_registration = models.OneToOneField( + EAPRegistration, + on_delete=models.CASCADE, + verbose_name=_("EAP Development Registration"), + related_name="simplified_eap", + ) + + # Contacts + # National Society + national_society_contact_name = models.CharField( + verbose_name=_("national society contact name"), max_length=255, null=True, blank=True + ) + 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, null=True, blank=True + ) + national_society_contact_phone_number = models.CharField( + verbose_name=_("national society contact phone number"), max_length=100, null=True, blank=True + ) + + # Partners NS + partner_ns_name = models.CharField(verbose_name=_("Partner NS name"), max_length=255, null=True, blank=True) + partner_ns_email = models.CharField(verbose_name=_("Partner NS email"), max_length=255, null=True, blank=True) + partner_ns_title = models.CharField(verbose_name=_("Partner NS title"), max_length=255, null=True, blank=True) + partner_ns_phone_number = models.CharField( + verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True + ) + + # Delegations + ifrc_delegation_focal_point_name = models.CharField( + verbose_name=_("IFRC delegation focal point name"), max_length=255, null=True, blank=True + ) + ifrc_delegation_focal_point_email = models.CharField( + verbose_name=_("IFRC delegation focal point email"), max_length=255, null=True, blank=True + ) + 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, null=True, blank=True + ) + ifrc_head_of_delegation_email = models.CharField( + verbose_name=_("IFRC head of delegation email"), max_length=255, null=True, blank=True + ) + 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 + ) + + ## RISK ANALYSIS and EARLY ACTION SELECTION ## + + ## RISK ANALYSIS ## + prioritized_hazard_and_impact = models.TextField( + verbose_name=_("Prioritized Hazard and its historical impact."), + null=True, + blank=True, + ) + # TODO(susilnem): Add image max 5 + + risks_selected_protocols = models.TextField( + verbose_name=_("Risk selected for the protocols."), + null=True, + blank=True, + ) + # TODO(susilnem): Add image max 5 + + ## EARLY ACTION SELECTION ## + selected_early_actions = models.TextField( + verbose_name=_("Selected Early Actions"), + null=True, + blank=True, + ) + # TODO(susilnem): Add image max 5 + + ## 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."), + null=True, + blank=True, + ) + + # TODO(susilnem): Discuss and add selections regions + potential_geographical_high_risk_areas = models.TextField( + verbose_name=_("Potential geographical high-risk areas"), + null=True, + blank=True, + ) + people_targeted = models.IntegerField( + verbose_name=_("People Targeted."), + null=True, + blank=True, + ) + assisted_through_operation = models.TextField( + verbose_name=_("Assisted through the operation"), + null=True, + blank=True, + ) + 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, + ) + + seap_lead_time = models.IntegerField( + verbose_name=_("sEAP Lead Time (Hours)"), + null=True, + blank=True, + ) + operational_timeframe = models.IntegerField( + verbose_name=_("Operational Timeframe (Months)"), + null=True, + blank=True, + ) + trigger_threshold_justification = models.TextField( + verbose_name=_("Trigger Threshold Justification"), + help_text=_("Explain how the trigger were set and provide information"), + null=True, + blank=True, + ) + next_step_towards_full_eap = models.TextField( + verbose_name=_("Next Steps towards Full EAP"), + ) + + ## PLANNED OPEATIONS ## + # TODO(susilnem): continue + + + class Meta: + verbose_name = _("Simplified EAP") + verbose_name_plural = _("Simplified EAPs") + + def __str__(self): + return f"Simplified EAP for {self.eap_registration}" From f1e5daef945b634b6567f31e959d9f8314ea5146 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 6 Nov 2025 11:59:19 +0545 Subject: [PATCH 07/57] feat(eap): Add Base Model and serializer --- eap/migrations/0003_eapregistration.py | 51 -------- .../0003_eapregistration_simplifiedeap.py | 115 ++++++++++++++++++ eap/models.py | 38 +++--- eap/serializers.py | 31 ++++- main/enums.py | 4 +- 5 files changed, 163 insertions(+), 76 deletions(-) delete mode 100644 eap/migrations/0003_eapregistration.py create mode 100644 eap/migrations/0003_eapregistration_simplifiedeap.py diff --git a/eap/migrations/0003_eapregistration.py b/eap/migrations/0003_eapregistration.py deleted file mode 100644 index 7b56ad7a4..000000000 --- a/eap/migrations/0003_eapregistration.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-05 07:49 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('api', '0226_nsdinitiativescategory_and_more'), - ('eap', '0002_auto_20220708_0747'), - ] - - operations = [ - 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, 'Approved'), (60, 'PFA Signed')], default=10, help_text='Select the current status of the EAP development process.', verbose_name='EAP Status')), - ('is_active', models.BooleanField(default=False, help_text='Indicates whether this EAP development registration is active.', verbose_name='Is Active')), - ('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')), - ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), - ('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(null=True, 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')), - ('modified_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified_by', to=settings.AUTH_USER_MODEL, verbose_name='modified by')), - ('national_society', 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)')), - ('partners', 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')), - ], - options={ - 'verbose_name': 'Development Registration EAP', - 'verbose_name_plural': 'Development Registration EAPs', - }, - ), - ] diff --git a/eap/migrations/0003_eapregistration_simplifiedeap.py b/eap/migrations/0003_eapregistration_simplifiedeap.py new file mode 100644 index 000000000..6aa35d970 --- /dev/null +++ b/eap/migrations/0003_eapregistration_simplifiedeap.py @@ -0,0 +1,115 @@ +# Generated by Django 4.2.19 on 2025-11-06 06:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0226_nsdinitiativescategory_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('eap', '0002_auto_20220708_0747'), + ] + + operations = [ + 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, 'Approved'), (60, 'PFA Signed')], default=10, help_text='Select the current status of the EAP development process.', verbose_name='EAP Status')), + ('is_active', models.BooleanField(default=False, help_text='Indicates whether this EAP development registration is active.', verbose_name='Is Active')), + ('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')), + ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), + ('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')), + ('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')), + ('national_society', 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)')), + ('partners', 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')), + ], + options={ + 'verbose_name': 'Development Registration EAP', + 'verbose_name_plural': 'Development Registration EAPs', + }, + ), + 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')), + ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), + ('partner_ns_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS name')), + ('partner_ns_email', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS email')), + ('partner_ns_title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS title')), + ('partner_ns_phone_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Partner NS phone number')), + ('ifrc_delegation_focal_point_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='IFRC delegation focal point name')), + ('ifrc_delegation_focal_point_email', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, verbose_name='IFRC head of delegation name')), + ('ifrc_head_of_delegation_email', models.CharField(blank=True, max_length=255, null=True, 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')), + ('prioritized_hazard_and_impact', models.TextField(blank=True, null=True, verbose_name='Prioritized Hazard and its historical impact.')), + ('risks_selected_protocols', models.TextField(blank=True, null=True, verbose_name='Risk selected for the protocols.')), + ('selected_early_actions', models.TextField(blank=True, null=True, verbose_name='Selected Early Actions')), + ('overall_objective_intervention', models.TextField(blank=True, help_text='Provide an objective statement that describe the main of the intervention.', null=True, verbose_name='Overall objective of the intervention')), + ('potential_geographical_high_risk_areas', models.TextField(blank=True, null=True, verbose_name='Potential geographical high-risk areas')), + ('people_targeted', models.IntegerField(blank=True, null=True, verbose_name='People Targeted.')), + ('assisted_through_operation', models.TextField(blank=True, null=True, 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_time', models.IntegerField(blank=True, null=True, verbose_name='sEAP Lead Time (Hours)')), + ('operational_timeframe', models.IntegerField(blank=True, null=True, verbose_name='Operational Timeframe (Months)')), + ('trigger_threshold_justification', models.TextField(blank=True, help_text='Explain how the trigger were set and provide information', null=True, verbose_name='Trigger Threshold Justification')), + ('next_step_towards_full_eap', models.TextField(verbose_name='Next Steps towards Full EAP')), + ('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.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='simplified_eap', to='eap.eapregistration', verbose_name='EAP Development Registration')), + ('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': 'Simplified EAP', + 'verbose_name_plural': 'Simplified EAPs', + }, + ), + ] diff --git a/eap/models.py b/eap/models.py index b265196ea..b28e69fb3 100644 --- a/eap/models.py +++ b/eap/models.py @@ -228,17 +228,23 @@ class EAPBaseModel(models.Model): settings.AUTH_USER_MODEL, verbose_name=_("created by"), on_delete=models.PROTECT, - null=True, related_name="%(class)s_created_by", ) modified_by = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_("modified by"), - on_delete=models.SET_NULL, - null=True, + on_delete=models.PROTECT, related_name="%(class)s_modified_by", ) + class Meta: + abstract = True + + +# BASE MODEL FOR EAP +class EAPRegistration(EAPBaseModel): + """Model representing the EAP Development Registration.""" + # National Society national_society = models.ForeignKey( Country, @@ -262,15 +268,6 @@ class EAPBaseModel(models.Model): on_delete=models.PROTECT, help_text=_("Select the disaster type for which the EAP is needed"), ) - - class Meta: - abstract = True - - -# BASE MODEL FOR EAP -class EAPRegistration(EAPBaseModel): - """Model representing the EAP Development Registration.""" - eap_type = models.IntegerField( choices=EAPType.choices, verbose_name=_("EAP Type"), @@ -349,7 +346,7 @@ def __str__(self): return f"EAP Development Registration - {self.national_society} - {self.disaster_type} - {self.get_eap_type_display()}" -class SimplifiedEAP(models.Model): +class SimplifiedEAP(EAPBaseModel): """Model representing a Simplified EAP.""" eap_registration = models.OneToOneField( @@ -378,9 +375,7 @@ class SimplifiedEAP(models.Model): partner_ns_name = models.CharField(verbose_name=_("Partner NS name"), max_length=255, null=True, blank=True) partner_ns_email = models.CharField(verbose_name=_("Partner NS email"), max_length=255, null=True, blank=True) partner_ns_title = models.CharField(verbose_name=_("Partner NS title"), max_length=255, null=True, blank=True) - partner_ns_phone_number = models.CharField( - verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True - ) + partner_ns_phone_number = models.CharField(verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True) # Delegations ifrc_delegation_focal_point_name = models.CharField( @@ -474,9 +469,9 @@ class SimplifiedEAP(models.Model): verbose_name=_("IFRC global ops coordinator phone number"), max_length=100, null=True, blank=True ) - ## RISK ANALYSIS and EARLY ACTION SELECTION ## + # RISK ANALYSIS and EARLY ACTION SELECTION # - ## RISK ANALYSIS ## + # RISK ANALYSIS # prioritized_hazard_and_impact = models.TextField( verbose_name=_("Prioritized Hazard and its historical impact."), null=True, @@ -491,7 +486,7 @@ class SimplifiedEAP(models.Model): ) # TODO(susilnem): Add image max 5 - ## EARLY ACTION SELECTION ## + # EARLY ACTION SELECTION # selected_early_actions = models.TextField( verbose_name=_("Selected Early Actions"), null=True, @@ -499,7 +494,7 @@ class SimplifiedEAP(models.Model): ) # TODO(susilnem): Add image max 5 - ## EARLY ACTION INTERVENTION ## + # 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."), @@ -556,10 +551,9 @@ class SimplifiedEAP(models.Model): verbose_name=_("Next Steps towards Full EAP"), ) - ## PLANNED OPEATIONS ## + # PLANNED OPEATIONS # # TODO(susilnem): continue - class Meta: verbose_name = _("Simplified EAP") verbose_name_plural = _("Simplified EAPs") diff --git a/eap/serializers.py b/eap/serializers.py index 3e9cba39e..6e3795294 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,3 +1,5 @@ +import typing + from rest_framework import serializers from api.serializers import MiniCountrySerializer, UserNameSerializer @@ -5,10 +7,34 @@ from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin +class BaseEAPSerializer(serializers.ModelSerializer): + + def get_fields(self): + fields = super().get_fields() + 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 EAPRegistrationSerializer( NestedUpdateMixin, NestedCreateMixin, - serializers.ModelSerializer, + BaseEAPSerializer, ): country_details = MiniCountrySerializer(source="country", read_only=True) national_society_details = MiniCountrySerializer(source="national_society", read_only=True) @@ -27,6 +53,9 @@ class Meta: model = EAPRegistration fields = "__all__" read_only_fields = [ + "is_active", "status", "modified_at", + "created_by", + "modified_by", ] diff --git a/main/enums.py b/main/enums.py index 176864317..c2d5786b5 100644 --- a/main/enums.py +++ b/main/enums.py @@ -5,11 +5,11 @@ 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 from per import enums as per_enums -from eap import enums as eap_enums apps_enum_register = [ ("dref", dref_enums.enum_register), @@ -20,7 +20,7 @@ ("notifications", notifications_enums.enum_register), ("databank", databank_enums.enum_register), ("local_units", local_units_enums.enum_register), - ("eap", eap_enums.enum_register) + ("eap", eap_enums.enum_register), ] From a77a9d40fa9ab4e4db54e4e47116701775745c43 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 6 Nov 2025 16:02:29 +0545 Subject: [PATCH 08/57] feat(eap): Add simplified model, operational, actions --- eap/enums.py | 2 + eap/models.py | 247 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 2 deletions(-) diff --git a/eap/enums.py b/eap/enums.py index 2db8a262d..1b53ff5d1 100644 --- a/eap/enums.py +++ b/eap/enums.py @@ -3,4 +3,6 @@ enum_register = { "eap_status": models.EAPStatus, "eap_type": models.EAPType, + "sector": models.PlannedOperations.Sector, + "timeframe": models.OperationActivity.TimeFrame, } diff --git a/eap/models.py b/eap/models.py index b28e69fb3..8d1a848d4 100644 --- a/eap/models.py +++ b/eap/models.py @@ -1,8 +1,10 @@ from django.conf import settings +from django.contrib.postgres.fields import ArrayField from django.db import models 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): @@ -179,9 +181,165 @@ def __str__(self): # --- Early Action Protocol --- ## +class EAPFile(models.Model): + file = SecureFileField( + verbose_name=_("file"), + upload_to="eap/files/", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("created_by"), + on_delete=models.CASCADE, + ) + caption = models.CharField(max_length=225, blank=True, null=True) + + class Meta: + verbose_name = _("eap file") + verbose_name_plural = _("eap files") + + +class OperationActivity(models.Model): + class TimeFrame(models.IntegerChoices): + YEARS = 10, _("Years") + MONTHS = 20, _("Months") + DAYS = 30, _("Days") + HOURS = 40, _("Hours") + + 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"), + ) + + class Meta: + verbose_name = _("Operation Activity") + verbose_name_plural = _("Operation Activities") + + def __str__(self): + return f"{self.activity}" + + +# TODO(susilnem): Verify indicarors? +# class OperationIndicator(models.Model): +# class IndicatorChoices(models.IntegerChoices): +# INDICATOR_1 = 10, _("Indicator 1") +# INDICATOR_2 = 20, _("Indicator 2") +# indicator = models.IntegerField(choices=IndicatorChoices.choices, verbose_name=_("Indicator")) + + +class PlannedOperations(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) + + # TODO(susilnem): verify indicators? + + # indicators = models.ManyToManyField( + # OperationIndicator, + # verbose_name=_("Operation Indicators"), + # blank=True, + # ) + + # 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 EnableApproach(models.Model): + class ApproachChoices(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=ApproachChoices.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) + indicator_target = models.IntegerField(verbose_name=_("Indicator Target"), null=True, blank=True) + + # TODO(susilnem): verify indicators? + # indicators = models.ManyToManyField( + # OperationIndicator, + # verbose_name=_("Operation Indicators"), + # blank=True, + # ) + + # Activities + readiness_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Readiness Activities"), + related_name="enable_approach_readiness_activities", + blank=True, + ) + prepositioning_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Pre-positioning Activities"), + related_name="enable_approach_prepositioning_activities", + blank=True, + ) + early_action_activities = models.ManyToManyField( + OperationActivity, + verbose_name=_("Early Action Activities"), + related_name="enable_approach_early_action_activities", + blank=True, + ) + + class Meta: + verbose_name = _("Enable Approach") + verbose_name_plural = _("Enable Approaches") + + def __str__(self): + return f"Enable Approach - {self.get_approach_display()}" + + 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): @@ -356,6 +514,19 @@ class SimplifiedEAP(EAPBaseModel): related_name="simplified_eap", ) + cover_image = models.ForeignKey( + EAPFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("cover image"), + related_name="cover_image_simplified_eap", + ) + seap_timeframe = models.IntegerField( + verbose_name=_("sEAP Timeframe (Years)"), + help_text=_("A simplified EAP has a timeframe of 2 years unless early action are activated."), + ) + # Contacts # National Society national_society_contact_name = models.CharField( @@ -477,6 +648,12 @@ class SimplifiedEAP(EAPBaseModel): null=True, blank=True, ) + hazard_impact = models.ManyToManyField( + EAPFile, + verbose_name=_("Hazard Impact Files"), + related_name="simplified_eap_hazard_impact_files", + blank=True, + ) # TODO(susilnem): Add image max 5 risks_selected_protocols = models.TextField( @@ -508,6 +685,13 @@ class SimplifiedEAP(EAPBaseModel): null=True, blank=True, ) + + admin2 = models.ManyToManyField( + Admin2, + verbose_name=_("admin2"), + blank=True, + ) + people_targeted = models.IntegerField( verbose_name=_("People Targeted."), null=True, @@ -552,7 +736,66 @@ class SimplifiedEAP(EAPBaseModel): ) # PLANNED OPEATIONS # - # TODO(susilnem): continue + planned_operations = models.ManyToManyField( + PlannedOperations, + verbose_name=_("Planned Operations"), + blank=True, + ) + + # ENABLE APPROACHES # + enable_approaches = models.ManyToManyField( + EnableApproach, + verbose_name=_("Enabling Approaches"), + related_name="simplified_eap_enable_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."), + null=True, + blank=True, + ) + rcrc_movement_involvement = models.TextField( + verbose_name=_("RCRC Movement Involvement."), + help_text=_("RCRC Movement partners, Governmental/other agencies consulted/involved."), + null=True, + blank=True, + ) + + # BUDGET # + total_budget = models.IntegerField( + verbose_name=_("Total Budget (CHF)"), + null=True, + blank=True, + ) + readiness_budget = models.IntegerField( + verbose_name=_("Readiness Budget (CHF)"), + null=True, + blank=True, + ) + pre_positioning_budget = models.IntegerField( + verbose_name=_("Pre-positioning Budget (CHF)"), + null=True, + blank=True, + ) + early_action_budget = models.IntegerField( + verbose_name=_("Early Actions Budget (CHF)"), + null=True, + blank=True, + ) + + # BUDGET DETAILS # + budget_file = models.ForeignKey( + EAPFile, + on_delete=models.SET_NULL, + verbose_name=_("Budget File"), + null=True, + ) class Meta: verbose_name = _("Simplified EAP") From 764e37315b9430e62bd2d75d71ce9f88c523943d Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 7 Nov 2025 16:44:36 +0545 Subject: [PATCH 09/57] feat(eap): Add test cases for eap registration and simplified - Add factories - Add eap file endpoint - Add update status endpoint --- eap/factories.py | 33 +++ ...apregistration_enableapproach_and_more.py} | 97 +++++++- eap/models.py | 105 ++++----- eap/serializers.py | 135 ++++++++++- eap/test_views.py | 217 ++++++++++++++++++ eap/views.py | 81 ++++++- main/urls.py | 4 +- 7 files changed, 599 insertions(+), 73 deletions(-) create mode 100644 eap/factories.py rename eap/migrations/{0003_eapregistration_simplifiedeap.py => 0003_eapfile_eapregistration_enableapproach_and_more.py} (62%) create mode 100644 eap/test_views.py diff --git a/eap/factories.py b/eap/factories.py new file mode 100644 index 000000000..787ed74de --- /dev/null +++ b/eap/factories.py @@ -0,0 +1,33 @@ + +import factory + +from factory import fuzzy + +from eap.models import EAPRegistration, EAPStatus, EAPType, SimplifiedEAP + +class EAPRegistrationFactory(factory.django.DjangoModelFactory): + class Meta: + model = EAPRegistration + + status = fuzzy.FuzzyChoice(EAPStatus) + eap_type = fuzzy.FuzzyChoice(EAPType) + + @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) diff --git a/eap/migrations/0003_eapregistration_simplifiedeap.py b/eap/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py similarity index 62% rename from eap/migrations/0003_eapregistration_simplifiedeap.py rename to eap/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py index 6aa35d970..a215e2649 100644 --- a/eap/migrations/0003_eapregistration_simplifiedeap.py +++ b/eap/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py @@ -1,8 +1,10 @@ -# Generated by Django 4.2.19 on 2025-11-06 06:13 +# Generated by Django 4.2.19 on 2025-11-07 06:33 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): @@ -14,6 +16,22 @@ class Migration(migrations.Migration): ] operations = [ + 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(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', + }, + ), migrations.CreateModel( name='EAPRegistration', fields=[ @@ -21,8 +39,7 @@ class Migration(migrations.Migration): ('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, 'Approved'), (60, 'PFA Signed')], default=10, help_text='Select the current status of the EAP development process.', verbose_name='EAP Status')), - ('is_active', models.BooleanField(default=False, help_text='Indicates whether this EAP development registration is active.', verbose_name='Is Active')), + ('status', models.IntegerField(choices=[(10, 'Under Development'), (20, 'Under Review'), (30, 'NS Addressing Comments'), (40, 'Technically Validated'), (50, 'Approved'), (60, 'PFA Signed'), (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')), ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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')), @@ -48,12 +65,57 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Development Registration EAPs', }, ), + migrations.CreateModel( + name='EnableApproach', + 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')), + ('indicator_target', models.IntegerField(blank=True, null=True, verbose_name='Indicator Target')), + ], + options={ + 'verbose_name': 'Enable Approach', + 'verbose_name_plural': 'Enable Approaches', + }, + ), + 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')), + ], + options={ + 'verbose_name': 'Operation Activity', + 'verbose_name_plural': 'Operation Activities', + }, + ), + migrations.CreateModel( + name='PlannedOperations', + 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')), + ('early_action_activities', models.ManyToManyField(blank=True, related_name='planned_operations_early_action_activities', to='eap.operationactivity', verbose_name='Early Action Activities')), + ('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='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')), + ('seap_timeframe', models.IntegerField(help_text='A simplified EAP has a timeframe of 2 years unless early action are activated.', verbose_name='sEAP Timeframe (Years)')), ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, verbose_name='national society contact email')), @@ -103,13 +165,42 @@ class Migration(migrations.Migration): ('operational_timeframe', models.IntegerField(blank=True, null=True, verbose_name='Operational Timeframe (Months)')), ('trigger_threshold_justification', models.TextField(blank=True, help_text='Explain how the trigger were set and provide information', null=True, verbose_name='Trigger Threshold Justification')), ('next_step_towards_full_eap', models.TextField(verbose_name='Next Steps towards Full EAP')), + ('early_action_capability', models.TextField(blank=True, help_text='Assumptions or minimum conditions needed to deliver the early actions.', null=True, verbose_name='Experience or Capacity to implement Early Action.')), + ('rcrc_movement_involvement', models.TextField(blank=True, help_text='RCRC Movement partners, Governmental/other agencies consulted/involved.', null=True, verbose_name='RCRC Movement Involvement.')), + ('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)')), + ('budget_file', main.fields.SecureFileField(blank=True, null=True, upload_to='eap/simplified_eap/budget_files/', verbose_name='Budget File')), + ('admin2', models.ManyToManyField(blank=True, to='api.admin2', verbose_name='admin2')), + ('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cover_image_simplified_eap', 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.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='simplified_eap', to='eap.eapregistration', verbose_name='EAP Development Registration')), + ('enable_approaches', models.ManyToManyField(blank=True, related_name='simplified_eap_enable_approaches', to='eap.enableapproach', verbose_name='Enabling Approaches')), + ('hazard_impact_file', models.ManyToManyField(blank=True, related_name='simplified_eap_hazard_impact_files', to='eap.eapfile', verbose_name='Hazard Impact 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')), + ('planned_operations', models.ManyToManyField(blank=True, to='eap.plannedoperations', verbose_name='Planned Operations')), + ('risk_selected_protocols_file', models.ManyToManyField(blank=True, related_name='simplified_eap_risk_selected_protocols_files', to='eap.eapfile', verbose_name='Risk Selected Protocols Files')), + ('selected_early_actions_file', models.ManyToManyField(blank=True, related_name='simplified_eap_selected_early_actions_files', to='eap.eapfile', verbose_name='Selected Early Actions Files')), ], options={ 'verbose_name': 'Simplified EAP', 'verbose_name_plural': 'Simplified EAPs', }, ), + migrations.AddField( + model_name='enableapproach', + name='early_action_activities', + field=models.ManyToManyField(blank=True, related_name='enable_approach_early_action_activities', to='eap.operationactivity', verbose_name='Early Action Activities'), + ), + migrations.AddField( + model_name='enableapproach', + name='prepositioning_activities', + field=models.ManyToManyField(blank=True, related_name='enable_approach_prepositioning_activities', to='eap.operationactivity', verbose_name='Pre-positioning Activities'), + ), + migrations.AddField( + model_name='enableapproach', + name='readiness_activities', + field=models.ManyToManyField(blank=True, related_name='enable_approach_readiness_activities', to='eap.operationactivity', verbose_name='Readiness Activities'), + ), ] diff --git a/eap/models.py b/eap/models.py index 8d1a848d4..a585f9e30 100644 --- a/eap/models.py +++ b/eap/models.py @@ -181,15 +181,39 @@ def __str__(self): # --- Early Action Protocol --- ## -class EAPFile(models.Model): - file = SecureFileField( - verbose_name=_("file"), - upload_to="eap/files/", +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.CASCADE, + 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", + ) + + class Meta: + abstract = True + + +class EAPFile(EAPBaseModel): + file = SecureFileField( + verbose_name=_("file"), + upload_to="eap/files/", ) caption = models.CharField(max_length=225, blank=True, null=True) @@ -369,34 +393,8 @@ class EAPStatus(models.IntegerChoices): PFA_SIGNED = 60, _("PFA Signed") """EAP should be APPROVED before changing to this status.""" - -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", - ) - - class Meta: - abstract = True + ACTIVATED = 70, _("Activated") + """EAP has been activated""" # BASE MODEL FOR EAP @@ -439,12 +437,6 @@ class EAPRegistration(EAPBaseModel): default=EAPStatus.UNDER_DEVELOPMENT, help_text=_("Select the current status of the EAP development process."), ) - # TODO(susilnem): Verify this field? - is_active = models.BooleanField( - verbose_name=_("Is Active"), - help_text=_("Indicates whether this EAP development registration is active."), - default=False, - ) expected_submission_time = models.DateField( verbose_name=_("Expected submission time"), @@ -648,20 +640,25 @@ class SimplifiedEAP(EAPBaseModel): null=True, blank=True, ) - hazard_impact = models.ManyToManyField( + hazard_impact_file = models.ManyToManyField( EAPFile, verbose_name=_("Hazard Impact Files"), related_name="simplified_eap_hazard_impact_files", blank=True, ) - # TODO(susilnem): Add image max 5 risks_selected_protocols = models.TextField( verbose_name=_("Risk selected for the protocols."), null=True, blank=True, ) - # TODO(susilnem): Add image max 5 + + risk_selected_protocols_file = models.ManyToManyField( + EAPFile, + verbose_name=_("Risk Selected Protocols Files"), + related_name="simplified_eap_risk_selected_protocols_files", + blank=True, + ) # EARLY ACTION SELECTION # selected_early_actions = models.TextField( @@ -669,7 +666,12 @@ class SimplifiedEAP(EAPBaseModel): null=True, blank=True, ) - # TODO(susilnem): Add image max 5 + selected_early_actions_file = models.ManyToManyField( + EAPFile, + verbose_name=_("Selected Early Actions Files"), + related_name="simplified_eap_selected_early_actions_files", + blank=True, + ) # EARLY ACTION INTERVENTION # overall_objective_intervention = models.TextField( @@ -679,7 +681,6 @@ class SimplifiedEAP(EAPBaseModel): blank=True, ) - # TODO(susilnem): Discuss and add selections regions potential_geographical_high_risk_areas = models.TextField( verbose_name=_("Potential geographical high-risk areas"), null=True, @@ -770,31 +771,23 @@ class SimplifiedEAP(EAPBaseModel): # BUDGET # total_budget = models.IntegerField( verbose_name=_("Total Budget (CHF)"), - null=True, - blank=True, ) readiness_budget = models.IntegerField( verbose_name=_("Readiness Budget (CHF)"), - null=True, - blank=True, ) pre_positioning_budget = models.IntegerField( verbose_name=_("Pre-positioning Budget (CHF)"), - null=True, - blank=True, ) early_action_budget = models.IntegerField( verbose_name=_("Early Actions Budget (CHF)"), - null=True, - blank=True, ) # BUDGET DETAILS # - budget_file = models.ForeignKey( - EAPFile, - on_delete=models.SET_NULL, + budget_file = SecureFileField( verbose_name=_("Budget File"), + upload_to="eap/simplified_eap/budget_files/", null=True, + blank=True, ) class Meta: diff --git a/eap/serializers.py b/eap/serializers.py index 6e3795294..809a20e9d 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -2,15 +2,24 @@ from rest_framework import serializers -from api.serializers import MiniCountrySerializer, UserNameSerializer -from eap.models import EAPRegistration +from api.serializers import Admin2Serializer, MiniCountrySerializer, UserNameSerializer +from eap.models import ( + EAPFile, + EAPRegistration, + EnableApproach, + OperationActivity, + PlannedOperations, + SimplifiedEAP, +) from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin +from utils.file_check import validate_file_type class BaseEAPSerializer(serializers.ModelSerializer): - def get_fields(self): fields = super().get_fields() + 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: @@ -23,6 +32,7 @@ def _set_user_fields(self, validated_data: dict[str, typing.Any], fields: list[s validated_data[field] = user def create(self, validated_data: dict[str, typing.Any]): + print("YEHA AYO", validated_data) self._set_user_fields(validated_data, ["created_by", "modified_by"]) return super().create(validated_data) @@ -42,10 +52,6 @@ class EAPRegistrationSerializer( eap_type_display = serializers.CharField(source="get_eap_type_display", read_only=True) - # User details - created_by_details = UserNameSerializer(source="created_by", read_only=True) - modified_by_details = UserNameSerializer(source="modified_by", read_only=True) - # Status status_display = serializers.CharField(source="get_status_display", read_only=True) @@ -59,3 +65,118 @@ class Meta: "created_by", "modified_by", ] + + +class EAPFileSerializer(BaseEAPSerializer): + id = serializers.IntegerField(required=False) + file = serializers.FileField(required=False) + + class Meta: + model = EAPFile + fields = "__all__" + read_only_fields = ( + "created_by", + "modified_by", + ) + + def validate_file(self, file): + validate_file_type(file) + return file + + +class OperationActivitySerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + + class Meta: + model = OperationActivity + fields = "__all__" + + +class PlannedOperationsSerializer( + NestedUpdateMixin, + NestedCreateMixin, + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + activities = OperationActivitySerializer(many=True, required=False) + + class Meta: + model = PlannedOperations + fields = "__all__" + + +class EnableApproachSerializer( + NestedUpdateMixin, + NestedCreateMixin, + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + + # 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 = EnableApproach + fields = "__all__" + read_only_fields = ( + "created_by", + "modified_by", + ) + + +class SimplifiedEAPSerializer( + NestedUpdateMixin, + NestedCreateMixin, + BaseEAPSerializer, +): + MAX_NUMBER_OF_IMAGES = 5 + eap_registration_details = EAPRegistrationSerializer(source="eap_registration", read_only=True) + + planned_operations = PlannedOperationsSerializer(many=True, required=False) + + # FILES + cover_image_details = EAPFileSerializer(source="cover_image", read_only=True) + hazard_impact_file_details = EAPFileSerializer(source="hazard_impact_file", many=True, read_only=True) + selected_early_actions_file_details = EAPFileSerializer(source="selected_early_actions_file", many=True, read_only=True) + risk_selected_protocols_file_details = EAPFileSerializer(source="risk_selected_protocols_file", many=True, read_only=True) + + # Admin2 + admin2_details = Admin2Serializer(source="admin2", read_only=True) + + class Meta: + model = SimplifiedEAP + fields = "__all__" + + def validate_hazard_impact_file(self, images): + if images and len(images) > self.MAX_NUMBER_OF_IMAGES: + raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") + return images + + def validate_risk_selected_protocols_file(self, images): + if images and len(images) > self.MAX_NUMBER_OF_IMAGES: + raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") + return images + + def validate_selected_early_actions_file(self, images): + if images and len(images) > self.MAX_NUMBER_OF_IMAGES: + raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") + return images + + +class EAPStatusSerializer( + BaseEAPSerializer, +): + status_display = serializers.CharField(source="get_status_display", read_only=True) + + class Meta: + model = EAPRegistration + fields = [ + "id", + "status", + ] + + # TODO(susilnem): Add status state validations diff --git a/eap/test_views.py b/eap/test_views.py new file mode 100644 index 000000000..54cad5f21 --- /dev/null +++ b/eap/test_views.py @@ -0,0 +1,217 @@ +from api.factories.country import CountryFactory +from api.factories.disaster_type import DisasterTypeFactory +from eap.factories import EAPRegistrationFactory, SimplifiedEAPFactory +from eap.models import EAPStatus, EAPType +from main.test_case import APITestCase + + +class EAPRegistrationTestCase(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="XX") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="YYY", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + + 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.user, + modified_by=self.user, + ) + 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) + + def test_create_eap_registration(self): + 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], + } + + self.authenticate() + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["eap_type"], EAPType.FULL_EAP) + self.assertEqual(response.data["status"], EAPStatus.UNDER_DEVELOPMENT) + # Check created_by + self.assertIsNotNone(response.data["created_by_details"]) + self.assertEqual( + response.data["created_by_details"]["id"], + self.user.id, + ) + + 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.user, + modified_by=self.user, + ) + url = f"/api/v2/eap-registration/{eap_registration.id}/" + + self.authenticate() + 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, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id], + created_by=self.user, + modified_by=self.user, + ) + 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.put(url, data, format="json") + self.assertEqual(response.status_code, 200) + + # 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) + + +class EAPSimplifiedTestCase(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="XX") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="YYY", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + + + def test_list_simplified_eap(self): + eap_registrations = EAPRegistrationFactory.create_batch( + 5, + 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, + ) + + for eap in eap_registrations: + SimplifiedEAPFactory.create( + eap_registration=eap, + created_by=self.user, + modified_by=self.user, + ) + + 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( + 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, + ) + data = { + "eap_registration": eap_registration.id, + "total_budget": 10000, + "seap_timeframe": 3, + "readiness_budget": 3000, + "pre_positioning_budget": 4000, + "early_action_budget": 3000, + } + + self.authenticate() + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, 201) + + def test_update_simplified_eap(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.user, + modified_by=self.user, + ) + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=eap_registration, + created_by=self.user, + modified_by=self.user, + ) + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + + data = { + "eap_registration": eap_registration.id, + "total_budget": 20000, + "seap_timeframe": 4, + "readiness_budget": 8000, + "pre_positioning_budget": 7000, + "early_action_budget": 5000, + } + + # Authenticate as root user + self.authenticate(self.root_user) + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["eap_registration_details"]["id"], + self.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, + ) diff --git a/eap/views.py b/eap/views.py index 24a2eeff7..1fbf019dd 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,19 +1,32 @@ # Create your views here. -from rest_framework import permissions, viewsets +from django.db.models.query import QuerySet +from rest_framework import mixins, permissions, response, viewsets +from rest_framework.decorators import action from eap.filter_set import EAPRegistrationFilterSet -from eap.models import EAPRegistration -from eap.serializers import EAPRegistrationSerializer -from main.permissions import DenyGuestUserMutationPermission +from eap.models import EAPFile, EAPRegistration, SimplifiedEAP +from eap.serializers import ( + EAPFileSerializer, + EAPRegistrationSerializer, + EAPStatusSerializer, + SimplifiedEAPSerializer, +) +from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission -class EAPRegistrationViewset(viewsets.ModelViewSet): +class EAPRegistrationViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.CreateModelMixin, +): queryset = EAPRegistration.objects.all() serializer_class = EAPRegistrationSerializer permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] filterset_class = EAPRegistrationFilterSet - def get_queryset(self): + def get_queryset(self) -> QuerySet[EAPRegistration]: return ( super() .get_queryset() @@ -27,3 +40,59 @@ def get_queryset(self): "partners", ) ) + + @action( + detail=True, + url_path="status", + methods=["post"], + serializer_class=EAPStatusSerializer, + permission_classes=[permissions.IsAuthenticated, DenyGuestUserPermission], + ) + def update_status( + self, + request, + pk: int | None = None, + ): + 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(viewsets.ModelViewSet): + queryset = SimplifiedEAP.objects.all() + serializer_class = SimplifiedEAPSerializer + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] + + def get_queryset(self) -> QuerySet[SimplifiedEAP]: + return ( + super() + .get_queryset() + .select_related( + "created_by", + "modified_by", + "cover_image", + ) + .prefetch_related( + "eap_registration", "admin2", "hazard_impact_file", "selected_early_actions_file", "risk_selected_protocols_file" + ) + ) + + +class EAPFileViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, +): + 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) diff --git a/main/urls.py b/main/urls.py index 911029130..f526b74d4 100644 --- a/main/urls.py +++ b/main/urls.py @@ -194,7 +194,9 @@ router.register(r"country-income", data_bank_views.FDRSIncomeViewSet, basename="country_income") # EAP(Early Action Protocol) -router.register(r"eap-registration", eap_views.EAPRegistrationViewset, basename="development_registration_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"eap-file", eap_views.EAPFileViewSet, basename="eap_file") admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin" From ae0263517e2b7083fe8de17b7712e63cb77cfeb6 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Sat, 8 Nov 2025 21:41:05 +0545 Subject: [PATCH 10/57] feat(eap): Add Simplified Admin, FilterSet, Status update endpoints - Update test cases - Add EAPBaseViewSet - Update on BaseEAPSerializer --- eap/admin.py | 49 ++++++++++++++++++++++++++++++++++++++++++++-- eap/factories.py | 3 +-- eap/filter_set.py | 8 +++++++- eap/serializers.py | 39 +++++++++++++++++++++++++++++++++--- eap/test_views.py | 16 ++++++++------- eap/views.py | 40 +++++++++++++++++++++++++++++-------- 6 files changed, 132 insertions(+), 23 deletions(-) diff --git a/eap/admin.py b/eap/admin.py index 8b5a4b1c6..c18b5192c 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from eap.models import EAPRegistration +from eap.models import EAPRegistration, SimplifiedEAP @admin.register(EAPRegistration) @@ -13,7 +13,7 @@ class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): ) list_filter = ("eap_type",) list_display = ( - "national_society", + "national_society_name", "country", "eap_type", "disaster_type", @@ -26,6 +26,11 @@ class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): "modified_by", ) + 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() @@ -41,3 +46,43 @@ def get_queryset(self, request): "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 = ("eap_registration",) + autocomplete_fields = ( + "eap_registration", + "created_by", + "modified_by", + "admin2", + ) + readonly_fields = ( + "cover_image", + "hazard_impact_file", + "risk_selected_protocols_file", + "selected_early_actions_file", + "planned_operations", + "enable_approaches", + ) + + 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", + ) + ) diff --git a/eap/factories.py b/eap/factories.py index 787ed74de..87b8394bb 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -1,10 +1,9 @@ - import factory - from factory import fuzzy from eap.models import EAPRegistration, EAPStatus, EAPType, SimplifiedEAP + class EAPRegistrationFactory(factory.django.DjangoModelFactory): class Meta: model = EAPRegistration diff --git a/eap/filter_set.py b/eap/filter_set.py index 37fc46fc8..9f9ab069d 100644 --- a/eap/filter_set.py +++ b/eap/filter_set.py @@ -1,7 +1,7 @@ import django_filters as filters from api.models import Country, DisasterType -from eap.models import EAPRegistration, EAPType +from eap.models import EAPRegistration, EAPType, SimplifiedEAP class BaseEAPFilterSet(filters.FilterSet): @@ -38,3 +38,9 @@ class EAPRegistrationFilterSet(BaseEAPFilterSet): class Meta: model = EAPRegistration fields = () + + +class SimplifiedEAPFilterSet(BaseEAPFilterSet): + class Meta: + model = SimplifiedEAP + fields = ("eap_registration",) diff --git a/eap/serializers.py b/eap/serializers.py index 809a20e9d..30646c045 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,5 +1,6 @@ import typing +from django.contrib.auth import get_user_model from rest_framework import serializers from api.serializers import Admin2Serializer, MiniCountrySerializer, UserNameSerializer @@ -14,10 +15,22 @@ from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin from utils.file_check import validate_file_type +User = get_user_model() + class BaseEAPSerializer(serializers.ModelSerializer): def get_fields(self): fields = super().get_fields() + # NOTE: Setting `created_by` and `modified_by` required to Flase + 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 @@ -32,7 +45,6 @@ def _set_user_fields(self, validated_data: dict[str, typing.Any], fields: list[s validated_data[field] = user def create(self, validated_data: dict[str, typing.Any]): - print("YEHA AYO", validated_data) self._set_user_fields(validated_data, ["created_by", "modified_by"]) return super().create(validated_data) @@ -41,6 +53,23 @@ def update(self, instance, validated_data: dict[str, typing.Any]): return super().update(instance, validated_data) +class MiniSimplifiedEAPSerializer( + serializers.ModelSerializer, +): + class Meta: + model = SimplifiedEAP + fields = [ + "id", + "eap_registration", + "total_budget", + "readiness_budget", + "pre_positioning_budget", + "early_action_budget", + "seap_timeframe", + "budget_file", + ] + + class EAPRegistrationSerializer( NestedUpdateMixin, NestedCreateMixin, @@ -52,6 +81,9 @@ class EAPRegistrationSerializer( eap_type_display = serializers.CharField(source="get_eap_type_display", read_only=True) + # EAPs + simplified_eap_details = MiniSimplifiedEAPSerializer(source="simplified_eap", read_only=True) + # Status status_display = serializers.CharField(source="get_status_display", read_only=True) @@ -134,9 +166,9 @@ class SimplifiedEAPSerializer( BaseEAPSerializer, ): MAX_NUMBER_OF_IMAGES = 5 - eap_registration_details = EAPRegistrationSerializer(source="eap_registration", read_only=True) planned_operations = PlannedOperationsSerializer(many=True, required=False) + enable_approach = EnableApproachSerializer(many=False, required=False) # FILES cover_image_details = EAPFileSerializer(source="cover_image", read_only=True) @@ -145,7 +177,7 @@ class SimplifiedEAPSerializer( risk_selected_protocols_file_details = EAPFileSerializer(source="risk_selected_protocols_file", many=True, read_only=True) # Admin2 - admin2_details = Admin2Serializer(source="admin2", read_only=True) + admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) class Meta: model = SimplifiedEAP @@ -176,6 +208,7 @@ class Meta: model = EAPRegistration fields = [ "id", + "status_display", "status", ] diff --git a/eap/test_views.py b/eap/test_views.py index 54cad5f21..5cc46f254 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -126,7 +126,6 @@ def setUp(self): self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") - def test_list_simplified_eap(self): eap_registrations = EAPRegistrationFactory.create_batch( 5, @@ -168,13 +167,17 @@ def test_create_simplified_eap(self): "readiness_budget": 3000, "pre_positioning_budget": 4000, "early_action_budget": 3000, + "next_step_towards_full_eap": "Plan to expand.", } self.authenticate() response = self.client.post(url, data, format="json") - self.assertEqual(response.status_code, 201) + # 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( country=self.country, @@ -194,19 +197,18 @@ def test_update_simplified_eap(self): data = { "eap_registration": eap_registration.id, "total_budget": 20000, - "seap_timeframe": 4, "readiness_budget": 8000, "pre_positioning_budget": 7000, "early_action_budget": 5000, - } + } # Authenticate as root user self.authenticate(self.root_user) - response = self.client.put(url, data, format="json") + response = self.client.patch(url, data, format="json") self.assertEqual(response.status_code, 200) self.assertEqual( - response.data["eap_registration_details"]["id"], - self.eap_registration.id, + response.data["eap_registration"], + eap_registration.id, ) # Check modified_by diff --git a/eap/views.py b/eap/views.py index 1fbf019dd..5f18e542a 100644 --- a/eap/views.py +++ b/eap/views.py @@ -3,7 +3,7 @@ from rest_framework import mixins, permissions, response, viewsets from rest_framework.decorators import action -from eap.filter_set import EAPRegistrationFilterSet +from eap.filter_set import EAPRegistrationFilterSet, SimplifiedEAPFilterSet from eap.models import EAPFile, EAPRegistration, SimplifiedEAP from eap.serializers import ( EAPFileSerializer, @@ -14,14 +14,19 @@ from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission -class EAPRegistrationViewSet( +class EAPModelViewSet( viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, mixins.CreateModelMixin, + mixins.UpdateModelMixin, ): + pass + + +class EAPRegistrationViewSet(EAPModelViewSet): queryset = EAPRegistration.objects.all() + lookup_field = "id" serializer_class = EAPRegistrationSerializer permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] filterset_class = EAPRegistrationFilterSet @@ -35,9 +40,11 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: "modified_by", "national_society", "disaster_type", + "country", ) .prefetch_related( "partners", + "simplified_eap", ) ) @@ -51,7 +58,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: def update_status( self, request, - pk: int | None = None, + id: int, ): eap_registration = self.get_object() serializer = self.get_serializer( @@ -63,9 +70,11 @@ def update_status( return response.Response(serializer.data) -class SimplifiedEAPViewSet(viewsets.ModelViewSet): +class SimplifiedEAPViewSet(EAPModelViewSet): queryset = SimplifiedEAP.objects.all() + lookup_field = "id" serializer_class = SimplifiedEAPSerializer + filterset_class = SimplifiedEAPFilterSet permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] def get_queryset(self) -> QuerySet[SimplifiedEAP]: @@ -76,23 +85,38 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: "created_by", "modified_by", "cover_image", + "eap_registration__country", + "eap_registration__disaster_type", ) .prefetch_related( - "eap_registration", "admin2", "hazard_impact_file", "selected_early_actions_file", "risk_selected_protocols_file" + "eap_registration__partners", + "admin2", + "hazard_impact_file", + "selected_early_actions_file", + "risk_selected_protocols_file", + "selected_early_actions_file", + "planned_operations", + "enable_approaches", ) ) class EAPFileViewSet( viewsets.GenericViewSet, - mixins.ListModelMixin, 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) + return EAPFile.objects.filter( + created_by=self.request.user, + ).select_related( + "created_by", + "modified_by", + ) From e17859e35a8407e92c5739e9100d42343d172468 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 11 Nov 2025 12:37:36 +0545 Subject: [PATCH 11/57] feat(eap): Add validations, multiple file upload - Update test cases - Add validations on SimplifiedEAP - Add update validations check on registrations --- eap/enums.py | 3 +- eap/factories.py | 113 +++- eap/filter_set.py | 6 +- ...name_plannedoperations_plannedoperation.py | 17 + eap/models.py | 46 +- eap/serializers.py | 49 +- eap/test_views.py | 551 +++++++++++++++++- eap/views.py | 20 +- 8 files changed, 782 insertions(+), 23 deletions(-) create mode 100644 eap/migrations/0004_rename_plannedoperations_plannedoperation.py diff --git a/eap/enums.py b/eap/enums.py index 1b53ff5d1..4204bd845 100644 --- a/eap/enums.py +++ b/eap/enums.py @@ -3,6 +3,7 @@ enum_register = { "eap_status": models.EAPStatus, "eap_type": models.EAPType, - "sector": models.PlannedOperations.Sector, + "sector": models.PlannedOperation.Sector, "timeframe": models.OperationActivity.TimeFrame, + "approach": models.EnableApproach.Approach, } diff --git a/eap/factories.py b/eap/factories.py index 87b8394bb..33631accc 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -1,7 +1,17 @@ +from random import random + import factory from factory import fuzzy -from eap.models import EAPRegistration, EAPStatus, EAPType, SimplifiedEAP +from eap.models import ( + EAPRegistration, + EAPStatus, + EAPType, + EnableApproach, + OperationActivity, + PlannedOperation, + SimplifiedEAP, +) class EAPRegistrationFactory(factory.django.DjangoModelFactory): @@ -30,3 +40,104 @@ class Meta: readiness_budget = fuzzy.FuzzyInteger(1000, 1000000) pre_positioning_budget = fuzzy.FuzzyInteger(1000, 1000000) early_action_budget = fuzzy.FuzzyInteger(1000, 1000000) + + @factory.post_generation + def enable_approaches(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for approach in extracted: + self.enable_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(OperationActivity.TimeFrame) + time_value = factory.LazyFunction(lambda: [random.randint(1, 12) for _ in range(3)]) + + +class EnableApproachFactory(factory.django.DjangoModelFactory): + class Meta: + model = EnableApproach + + approach = fuzzy.FuzzyChoice(EnableApproach.Approach) + budget_per_approach = fuzzy.FuzzyInteger(1000, 1000000) + ap_code = fuzzy.FuzzyInteger(100, 999) + indicator_target = fuzzy.FuzzyInteger(10, 1000) + + @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) diff --git a/eap/filter_set.py b/eap/filter_set.py index 9f9ab069d..1ca9814e6 100644 --- a/eap/filter_set.py +++ b/eap/filter_set.py @@ -1,7 +1,7 @@ import django_filters as filters from api.models import Country, DisasterType -from eap.models import EAPRegistration, EAPType, SimplifiedEAP +from eap.models import EAPRegistration, EAPStatus, EAPType, SimplifiedEAP class BaseEAPFilterSet(filters.FilterSet): @@ -34,6 +34,10 @@ class EAPRegistrationFilterSet(BaseEAPFilterSet): choices=EAPType.choices, label="EAP Type", ) + status = filters.ChoiceFilter( + choices=EAPStatus.choices, + label="EAP Status", + ) class Meta: model = EAPRegistration diff --git a/eap/migrations/0004_rename_plannedoperations_plannedoperation.py b/eap/migrations/0004_rename_plannedoperations_plannedoperation.py new file mode 100644 index 000000000..5fb6e477c --- /dev/null +++ b/eap/migrations/0004_rename_plannedoperations_plannedoperation.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.19 on 2025-11-11 06:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0003_eapfile_eapregistration_enableapproach_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name='PlannedOperations', + new_name='PlannedOperation', + ), + ] diff --git a/eap/models.py b/eap/models.py index a585f9e30..73ba89700 100644 --- a/eap/models.py +++ b/eap/models.py @@ -211,6 +211,7 @@ class Meta: class EAPFile(EAPBaseModel): + # TODO(susilnem): Make not nullable file = SecureFileField( verbose_name=_("file"), upload_to="eap/files/", @@ -252,7 +253,7 @@ def __str__(self): # indicator = models.IntegerField(choices=IndicatorChoices.choices, verbose_name=_("Indicator")) -class PlannedOperations(models.Model): +class PlannedOperation(models.Model): class Sector(models.IntegerChoices): SHELTER = 101, _("Shelter") SETTLEMENT_AND_HOUSING = 102, _("Settlement and Housing") @@ -311,12 +312,12 @@ def __str__(self): class EnableApproach(models.Model): - class ApproachChoices(models.IntegerChoices): + 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=ApproachChoices.choices, verbose_name=_("Approach")) + 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) indicator_target = models.IntegerField(verbose_name=_("Indicator Target"), null=True, blank=True) @@ -418,7 +419,7 @@ class EAPRegistration(EAPBaseModel): ) # Disaster - disaster_type = models.ForeignKey( + disaster_type = models.ForeignKey[DisasterType, DisasterType]( DisasterType, verbose_name=("Disaster Type"), on_delete=models.PROTECT, @@ -495,18 +496,46 @@ 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.""" + # TODO(susilnem): Add FULL EAP check, when model is created. + return hasattr(self, "simplified_eap") + + @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 SimplifiedEAP(EAPBaseModel): """Model representing a Simplified EAP.""" - eap_registration = models.OneToOneField( + eap_registration = models.OneToOneField[EAPRegistration, EAPRegistration]( EAPRegistration, on_delete=models.CASCADE, verbose_name=_("EAP Development Registration"), related_name="simplified_eap", ) - cover_image = models.ForeignKey( + cover_image = models.ForeignKey[EAPFile | None, EAPFile | None]( EAPFile, on_delete=models.SET_NULL, blank=True, @@ -738,7 +767,7 @@ class SimplifiedEAP(EAPBaseModel): # PLANNED OPEATIONS # planned_operations = models.ManyToManyField( - PlannedOperations, + PlannedOperation, verbose_name=_("Planned Operations"), blank=True, ) @@ -790,6 +819,9 @@ class SimplifiedEAP(EAPBaseModel): blank=True, ) + # TYPING + eap_registration_id: int + class Meta: verbose_name = _("Simplified EAP") verbose_name_plural = _("Simplified EAPs") diff --git a/eap/serializers.py b/eap/serializers.py index 30646c045..eaa76ee44 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -3,13 +3,19 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from api.serializers import Admin2Serializer, MiniCountrySerializer, UserNameSerializer +from api.serializers import ( + Admin2Serializer, + DisasterTypeSerializer, + MiniCountrySerializer, + UserNameSerializer, +) from eap.models import ( EAPFile, EAPRegistration, + EAPType, EnableApproach, OperationActivity, - PlannedOperations, + PlannedOperation, SimplifiedEAP, ) from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin @@ -80,6 +86,7 @@ class EAPRegistrationSerializer( 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_eap", read_only=True) @@ -98,10 +105,20 @@ class Meta: "modified_by", ] + def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]): + # 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 EAPFileInputSerializer(serializers.Serializer): + file = serializers.ListField(child=serializers.FileField(required=True)) + class EAPFileSerializer(BaseEAPSerializer): id = serializers.IntegerField(required=False) - file = serializers.FileField(required=False) + file = serializers.FileField(required=True) class Meta: model = EAPFile @@ -126,16 +143,20 @@ class Meta: fields = "__all__" -class PlannedOperationsSerializer( +class PlannedOperationSerializer( NestedUpdateMixin, NestedCreateMixin, serializers.ModelSerializer, ): id = serializers.IntegerField(required=False) - activities = OperationActivitySerializer(many=True, required=False) + + # 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 = PlannedOperations + model = PlannedOperation fields = "__all__" @@ -167,8 +188,8 @@ class SimplifiedEAPSerializer( ): MAX_NUMBER_OF_IMAGES = 5 - planned_operations = PlannedOperationsSerializer(many=True, required=False) - enable_approach = EnableApproachSerializer(many=False, required=False) + planned_operations = PlannedOperationSerializer(many=True, required=False) + enable_approaches = EnableApproachSerializer(many=True, required=False) # FILES cover_image_details = EAPFileSerializer(source="cover_image", read_only=True) @@ -198,6 +219,18 @@ def validate_selected_early_actions_file(self, images): raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") return images + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: + eap_registration: EAPRegistration = data["eap_registration"] + 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.") + 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) + return instance + class EAPStatusSerializer( BaseEAPSerializer, diff --git a/eap/test_views.py b/eap/test_views.py index 5cc46f254..657ff0dff 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1,10 +1,74 @@ +import os + +from django.conf import settings + from api.factories.country import CountryFactory from api.factories.disaster_type import DisasterTypeFactory -from eap.factories import EAPRegistrationFactory, SimplifiedEAPFactory -from eap.models import EAPStatus, EAPType +from eap.factories import ( + EAPRegistrationFactory, + EnableApproachFactory, + OperationActivityFactory, + PlannedOperationFactory, + SimplifiedEAPFactory, +) +from eap.models import ( + EAPFile, + EAPStatus, + EAPType, + EnableApproach, + OperationActivity, + PlannedOperation, +) 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() @@ -47,14 +111,26 @@ def test_create_eap_registration(self): response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["eap_type"], EAPType.FULL_EAP) - self.assertEqual(response.data["status"], EAPStatus.UNDER_DEVELOPMENT) # Check created_by self.assertIsNotNone(response.data["created_by_details"]) self.assertEqual( response.data["created_by_details"]["id"], self.user.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, + }, + ) def test_retrieve_eap_registration(self): eap_registration = EAPRegistrationFactory.create( @@ -113,6 +189,23 @@ def test_update_eap_registration(self): 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.user, + modified_by=self.user, + ) + + 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) + class EAPSimplifiedTestCase(APITestCase): def setUp(self): @@ -129,6 +222,7 @@ def setUp(self): 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, @@ -153,6 +247,7 @@ def test_list_simplified_eap(self): def test_create_simplified_eap(self): url = "/api/v2/simplified-eap/" eap_registration = EAPRegistrationFactory.create( + eap_type=EAPType.SIMPLIFIED_EAP, country=self.country, national_society=self.national_society, disaster_type=self.disaster_type, @@ -168,18 +263,86 @@ def test_create_simplified_eap(self): "pre_positioning_budget": 4000, "early_action_budget": 3000, "next_step_towards_full_eap": "Plan to expand.", + "planned_operations": [ + { + "sector": 101, + "ap_code": 111, + "people_targeted": 10000, + "budget_per_sector": 100000, + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [2, 3], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [2, 3], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [2147483647], + } + ], + } + ], + "enable_approaches": [ + { + "ap_code": 11, + "approach": 10, + "budget_per_approach": 10000, + "indicator_target": 10000, + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [2, 3], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [2, 3], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [2147483647], + } + ], + }, + ], } self.authenticate() response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, 201) + self.assertEqual( + response.data["eap_registration"], + eap_registration.id, + ) + self.assertEqual( + eap_registration.get_eap_type_enum, + EAPType.SIMPLIFIED_EAP, + ) + # 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, country=self.country, national_society=self.national_society, disaster_type=self.disaster_type, @@ -187,10 +350,113 @@ def test_update_simplified_eap(self): created_by=self.user, modified_by=self.user, ) + enable_approach_readiness_operation_activity_1 = OperationActivityFactory.create( + activity="Readiness Activity 1", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[1, 2], + ) + enable_approach_readiness_operation_activity_2 = OperationActivityFactory.create( + activity="Readiness Activity 2", + timeframe=OperationActivity.TimeFrame.YEARS, + time_value=[1, 5], + ) + enable_approach_prepositioning_operation_activity_1 = OperationActivityFactory.create( + activity="Prepositioning Activity 1", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[2, 4], + ) + enable_approach_prepositioning_operation_activity_2 = OperationActivityFactory.create( + activity="Prepositioning Activity 2", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[3, 6], + ) + enable_approach_early_action_operation_activity_1 = OperationActivityFactory.create( + activity="Early Action Activity 1", + timeframe=OperationActivity.TimeFrame.DAYS, + time_value=[5, 10], + ) + enable_approach_early_action_operation_activity_2 = OperationActivityFactory.create( + activity="Early Action Activity 2", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[1, 3], + ) + + # ENABLE APPROACH with activities + enable_approach = EnableApproachFactory.create( + approach=EnableApproach.Approach.SECRETARIAT_SERVICES, + budget_per_approach=5000, + ap_code=123, + indicator_target=500, + readiness_activities=[ + enable_approach_readiness_operation_activity_1.id, + enable_approach_readiness_operation_activity_2.id, + ], + prepositioning_activities=[ + enable_approach_prepositioning_operation_activity_1.id, + enable_approach_prepositioning_operation_activity_2.id, + ], + early_action_activities=[ + enable_approach_early_action_operation_activity_1.id, + enable_approach_early_action_operation_activity_2.id, + ], + ) + planned_operation_readiness_operation_activity_1 = OperationActivityFactory.create( + activity="Readiness Activity 1", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[1, 2], + ) + planned_operation_readiness_operation_activity_2 = OperationActivityFactory.create( + activity="Readiness Activity 2", + timeframe=OperationActivity.TimeFrame.YEARS, + time_value=[1, 5], + ) + planned_operation_prepositioning_operation_activity_1 = OperationActivityFactory.create( + activity="Prepositioning Activity 1", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[2, 4], + ) + planned_operation_prepositioning_operation_activity_2 = OperationActivityFactory.create( + activity="Prepositioning Activity 2", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[3, 6], + ) + planned_operation_early_action_operation_activity_1 = OperationActivityFactory.create( + activity="Early Action Activity 1", + timeframe=OperationActivity.TimeFrame.DAYS, + time_value=[5, 10], + ) + planned_operation_early_action_operation_activity_2 = OperationActivityFactory.create( + activity="Early Action Activity 2", + timeframe=OperationActivity.TimeFrame.MONTHS, + time_value=[1, 3], + ) + + # 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.user, modified_by=self.user, + enable_approaches=[enable_approach.id], + planned_operations=[planned_operation.id], ) url = f"/api/v2/simplified-eap/{simplified_eap.id}/" @@ -200,6 +466,128 @@ def test_update_simplified_eap(self): "readiness_budget": 8000, "pre_positioning_budget": 7000, "early_action_budget": 5000, + "enable_approaches": [ + { + "id": enable_approach.id, + "approach": EnableApproach.Approach.NATIONAL_SOCIETY_STRENGTHENING, + "budget_per_approach": 8000, + "ap_code": 123, + "indicator_target": 800, + "readiness_activities": [ + { + "id": enable_approach_readiness_operation_activity_1.id, + "activity": "Updated Enable Approach Readiness Activity 1", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [2, 3], + } + ], + "prepositioning_activities": [ + { + "id": enable_approach_prepositioning_operation_activity_1.id, + "activity": "Updated Enable Approach Prepositioning Activity 1", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [3, 5], + } + ], + "early_action_activities": [ + { + "id": enable_approach_early_action_operation_activity_1.id, + "activity": "Updated Enable Approach Early Action Activity 1", + "timeframe": OperationActivity.TimeFrame.DAYS, + "time_value": [7, 14], + } + ], + }, + # CREATE NEW Enable Approach + { + "approach": EnableApproach.Approach.PARTNERSHIP_AND_COORDINATION, + "budget_per_approach": 9000, + "ap_code": 124, + "indicator_target": 900, + "readiness_activities": [ + { + "activity": "New Enable Approach Readiness Activity", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [1, 2], + } + ], + "prepositioning_activities": [ + { + "activity": "New Enable Approach Prepositioning Activity", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [2, 4], + } + ], + "early_action_activities": [ + { + "activity": "New Enable Approach Early Action Activity", + "timeframe": OperationActivity.TimeFrame.DAYS, + "time_value": [5, 10], + } + ], + }, + ], + "planned_operations": [ + { + "id": planned_operation.id, + "sector": PlannedOperation.Sector.SHELTER, + "ap_code": 456, + "people_targeted": 8000, + "budget_per_sector": 80000, + "readiness_activities": [ + { + "id": planned_operation_readiness_operation_activity_1.id, + "activity": "Updated Planned Operation Readiness Activity 1", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [2, 4], + } + ], + "prepositioning_activities": [ + { + "id": planned_operation_prepositioning_operation_activity_1.id, + "activity": "Updated Planned Operation Prepositioning Activity 1", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [3, 6], + } + ], + "early_action_activities": [ + { + "id": planned_operation_early_action_operation_activity_1.id, + "activity": "Updated Planned Operation Early Action Activity 1", + "timeframe": OperationActivity.TimeFrame.DAYS, + "time_value": [8, 16], + } + ], + }, + { + # 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": OperationActivity.TimeFrame.MONTHS, + "time_value": [1, 3], + } + ], + "prepositioning_activities": [ + { + "activity": "New Planned Operation Prepositioning Activity", + "timeframe": OperationActivity.TimeFrame.MONTHS, + "time_value": [2, 5], + } + ], + "early_action_activities": [ + { + "activity": "New Planned Operation Early Action Activity", + "timeframe": OperationActivity.TimeFrame.DAYS, + "time_value": [5, 12], + } + ], + }, + ], } # Authenticate as root user @@ -217,3 +605,158 @@ def test_update_simplified_eap(self): response.data["modified_by_details"]["id"], self.root_user.id, ) + + # CHECK ENABLE APPROACH UPDATED + self.assertEqual(len(response.data["enable_approaches"]), 2) + self.assertEqual( + { + response.data["enable_approaches"][0]["id"], + response.data["enable_approaches"][0]["approach"], + response.data["enable_approaches"][0]["budget_per_approach"], + response.data["enable_approaches"][0]["ap_code"], + response.data["enable_approaches"][0]["indicator_target"], + # NEW DATA + response.data["enable_approaches"][1]["approach"], + response.data["enable_approaches"][1]["budget_per_approach"], + response.data["enable_approaches"][1]["ap_code"], + response.data["enable_approaches"][1]["indicator_target"], + }, + { + enable_approach.id, + data["enable_approaches"][0]["approach"], + data["enable_approaches"][0]["budget_per_approach"], + data["enable_approaches"][0]["ap_code"], + data["enable_approaches"][0]["indicator_target"], + # NEW DATA + data["enable_approaches"][1]["approach"], + data["enable_approaches"][1]["budget_per_approach"], + data["enable_approaches"][1]["ap_code"], + data["enable_approaches"][1]["indicator_target"], + }, + ) + self.assertEqual( + { + # READINESS ACTIVITY + response.data["enable_approaches"][0]["readiness_activities"][0]["id"], + response.data["enable_approaches"][0]["readiness_activities"][0]["activity"], + response.data["enable_approaches"][0]["readiness_activities"][0]["timeframe"], + # NEW READINESS ACTIVITY + response.data["enable_approaches"][1]["readiness_activities"][0]["activity"], + response.data["enable_approaches"][1]["readiness_activities"][0]["timeframe"], + # PREPOSITIONING ACTIVITY + response.data["enable_approaches"][0]["prepositioning_activities"][0]["id"], + response.data["enable_approaches"][0]["prepositioning_activities"][0]["activity"], + response.data["enable_approaches"][0]["prepositioning_activities"][0]["timeframe"], + # NEW PREPOSITIONING ACTIVITY + response.data["enable_approaches"][1]["prepositioning_activities"][0]["activity"], + response.data["enable_approaches"][1]["prepositioning_activities"][0]["timeframe"], + # EARLY ACTION ACTIVITY + response.data["enable_approaches"][0]["early_action_activities"][0]["id"], + response.data["enable_approaches"][0]["early_action_activities"][0]["activity"], + response.data["enable_approaches"][0]["early_action_activities"][0]["timeframe"], + # NEW EARLY ACTION ACTIVITY + response.data["enable_approaches"][1]["early_action_activities"][0]["activity"], + response.data["enable_approaches"][1]["early_action_activities"][0]["timeframe"], + }, + { + # READINESS ACTIVITY + enable_approach_readiness_operation_activity_1.id, + data["enable_approaches"][0]["readiness_activities"][0]["activity"], + data["enable_approaches"][0]["readiness_activities"][0]["timeframe"], + # NEW READINESS ACTIVITY + data["enable_approaches"][1]["readiness_activities"][0]["activity"], + data["enable_approaches"][1]["readiness_activities"][0]["timeframe"], + # PREPOSITIONING ACTIVITY + enable_approach_prepositioning_operation_activity_1.id, + data["enable_approaches"][0]["prepositioning_activities"][0]["activity"], + data["enable_approaches"][0]["prepositioning_activities"][0]["timeframe"], + # NEW PREPOSITIONING Activity + data["enable_approaches"][1]["prepositioning_activities"][0]["activity"], + data["enable_approaches"][1]["prepositioning_activities"][0]["timeframe"], + # EARLY ACTION ACTIVITY + enable_approach_early_action_operation_activity_1.id, + data["enable_approaches"][0]["early_action_activities"][0]["activity"], + data["enable_approaches"][0]["early_action_activities"][0]["timeframe"], + # NEW EARLY ACTION ACTIVITY + data["enable_approaches"][1]["early_action_activities"][0]["activity"], + data["enable_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"], + }, + ) diff --git a/eap/views.py b/eap/views.py index 5f18e542a..6a22e590f 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,11 +1,13 @@ # Create your views here. from django.db.models.query import QuerySet -from rest_framework import mixins, permissions, response, viewsets +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, permissions, response, status, viewsets from rest_framework.decorators import action from eap.filter_set import EAPRegistrationFilterSet, SimplifiedEAPFilterSet from eap.models import EAPFile, EAPRegistration, SimplifiedEAP from eap.serializers import ( + EAPFileInputSerializer, EAPFileSerializer, EAPRegistrationSerializer, EAPStatusSerializer, @@ -120,3 +122,19 @@ def get_queryset(self) -> QuerySet[EAPFile]: "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) From 2b0016cf7eea2f6ee5bffba509dc8b3260ffb6c1 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 12 Nov 2025 23:30:53 +0545 Subject: [PATCH 12/57] feat(eap): Add status transition validations and permissions - Add test cases - Update additional typing - Add Permissions for country admin and IFRC admin users on eap --- eap/models.py | 11 ++++ eap/serializers.py | 101 +++++++++++++++++++++++++++-- eap/test_views.py | 156 ++++++++++++++++++++++++++++++++++++++++++++- eap/utils.py | 27 ++++++++ 4 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 eap/utils.py diff --git a/eap/models.py b/eap/models.py index 73ba89700..4523b152d 100644 --- a/eap/models.py +++ b/eap/models.py @@ -402,6 +402,8 @@ class EAPStatus(models.IntegerChoices): class EAPRegistration(EAPBaseModel): """Model representing the EAP Development Registration.""" + Status = EAPStatus + # National Society national_society = models.ForeignKey( Country, @@ -488,7 +490,15 @@ class EAPRegistration(EAPBaseModel): verbose_name=_("Dref focal point phone number"), max_length=100, null=True, blank=True ) + # TYPING + national_society_id = int + country_id = int + disaster_type_id = int + id = int + class Meta: + # TODO(susilnem): Add ordering when created_at is added to the model. + # ordering = ['-created_at'] verbose_name = _("Development Registration EAP") verbose_name_plural = _("Development Registration EAPs") @@ -821,6 +831,7 @@ class SimplifiedEAP(EAPBaseModel): # TYPING eap_registration_id: int + id = int class Meta: verbose_name = _("Simplified EAP") diff --git a/eap/serializers.py b/eap/serializers.py index eaa76ee44..4888a807d 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,7 +1,9 @@ import typing -from django.contrib.auth import get_user_model +from django.contrib.auth.models import User +from django.utils.translation import gettext from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied from api.serializers import ( Admin2Serializer, @@ -18,11 +20,10 @@ PlannedOperation, SimplifiedEAP, ) +from eap.utils import has_country_permission, is_user_ifrc_admin from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin from utils.file_check import validate_file_type -User = get_user_model() - class BaseEAPSerializer(serializers.ModelSerializer): def get_fields(self): @@ -232,9 +233,27 @@ def create(self, validated_data: dict[str, typing.Any]): return instance -class EAPStatusSerializer( - BaseEAPSerializer, -): +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.NS_ADDRESSING_COMMENTS, EAPRegistration.Status.TECHNICALLY_VALIDATED), + (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.TECHNICALLY_VALIDATED), + (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.APPROVED), + (EAPRegistration.Status.APPROVED, EAPRegistration.Status.ACTIVATED), + ] +) + + +class EAPStatusSerializer(BaseEAPSerializer): status_display = serializers.CharField(source="get_status_display", read_only=True) class Meta: @@ -245,4 +264,72 @@ class Meta: "status", ] - # TODO(susilnem): Add status state validations + def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration.Status: + assert self.instance is not None, "EAP instance does not exist." + + 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 + + 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_REVIEW, + 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 + ) + + # TODO(susilnem): Check if review checklist has been uploaded. + # if not self.instance.review_checklist_file: + # raise serializers.ValidationError( + # gettext( + # "Review checklist file must be uploaded before changing status to %s." + # ) % EAPRegistration.Status(new_status).label + # ) + + 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): + raise PermissionDenied( + gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label + ) + + # TODO(susilnem): Check if NS Addressing Comments file has been uploaded. + # if not self.instance.ns_addressing_comments_file: + # raise serializers.ValidationError( + # gettext( + # "NS Addressing Comments file must be uploaded before changing status to %s." + # ) % EAPRegistration.Status(new_status).label + # ) + + elif (current_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.APPROVED, + ): + 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 + ) + + # TODO(susilnem): Check if validated budget file has been uploaded. + # 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 + # ) + + return new_status diff --git a/eap/test_views.py b/eap/test_views.py index 657ff0dff..6af04454b 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1,9 +1,12 @@ import os from django.conf import settings +from django.contrib.auth.models import Group, Permission +from django.core import management from api.factories.country import CountryFactory from api.factories.disaster_type import DisasterTypeFactory +from deployments.factories.user import UserFactory from eap.factories import ( EAPRegistrationFactory, EnableApproachFactory, @@ -72,7 +75,7 @@ def test_upload_invalid_files(self): class EAPRegistrationTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="XX") + self.country = CountryFactory.create(name="country1", iso3="XXX") self.national_society = CountryFactory.create( name="national_society1", iso3="YYY", @@ -210,7 +213,7 @@ def test_update_eap_registration(self): class EAPSimplifiedTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="XX") + self.country = CountryFactory.create(name="country1", iso3="XXX") self.national_society = CountryFactory.create( name="national_society1", iso3="YYY", @@ -760,3 +763,152 @@ def test_update_simplified_eap(self): data["planned_operations"][1]["early_action_activities"][0]["timeframe"], }, ) + + +class EAPStatusTransitionTestCase(APITestCase): + def setUp(self): + super().setUp() + + self.country = CountryFactory.create(name="country1", iso3="XXX") + self.national_society = CountryFactory.create( + name="national_society1", + iso3="YYY", + ) + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + + 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/" + + # TODO(susilnem): Update test case for file uploads once implemented + 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) + + SimplifiedEAPFactory.create( + eap_registration=self.eap_registration, + created_by=self.user, + modified_by=self.user, + ) + + # SUCCESS: As Simplified EAP exists + 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 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 + # 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.NS_ADDRESSING_COMMENTS) + + # NOTE: Transition to TECHNICALLY_VALIDATED + # NS_ADDRESSING_COMMENTS -> 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) + + # NOTE: Transition to APPROVED + # TECHNICALLY_VALIDATED -> 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.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) + + # 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.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) diff --git a/eap/utils.py b/eap/utils.py new file mode 100644 index 000000000..6f7db1a58 --- /dev/null +++ b/eap/utils.py @@ -0,0 +1,27 @@ +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User + + +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 From 7ad279f4fec597ee55ff7f22b9029c0f3482a94e Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 13 Nov 2025 17:05:06 +0545 Subject: [PATCH 13/57] feat(eap): Add status transition, timeline and validated budget file - Update test cases - Add validation checks on status transition - Add new endpoint for uploading validated budget file --- ...5_eapregistration_activated_at_and_more.py | 44 ++++++ eap/models.py | 42 +++++- eap/permissions.py | 65 +++++++++ eap/serializers.py | 88 +++++++++-- eap/test_views.py | 137 ++++++++++++++---- eap/utils.py | 3 +- eap/views.py | 31 +++- 7 files changed, 367 insertions(+), 43 deletions(-) create mode 100644 eap/migrations/0005_eapregistration_activated_at_and_more.py create mode 100644 eap/permissions.py diff --git a/eap/migrations/0005_eapregistration_activated_at_and_more.py b/eap/migrations/0005_eapregistration_activated_at_and_more.py new file mode 100644 index 000000000..70fa1ae9e --- /dev/null +++ b/eap/migrations/0005_eapregistration_activated_at_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.19 on 2025-11-13 10:51 + +from django.db import migrations, models +import main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0004_rename_plannedoperations_plannedoperation'), + ] + + operations = [ + migrations.AddField( + model_name='eapregistration', + name='activated_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the EAP was activated.', null=True, verbose_name='activated at'), + ), + migrations.AddField( + model_name='eapregistration', + name='approved_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the EAP was approved.', null=True, verbose_name='approved at'), + ), + migrations.AddField( + model_name='eapregistration', + name='pfa_signed_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the PFA was signed.', null=True, verbose_name='PFA signed at'), + ), + migrations.AddField( + model_name='eapregistration', + name='technically_validated_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the EAP was technically validated.', null=True, verbose_name='technically validated at'), + ), + migrations.AddField( + model_name='eapregistration', + name='validated_budget_file', + field=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'), + ), + migrations.AlterField( + model_name='eapfile', + name='file', + field=main.fields.SecureFileField(help_text='Upload EAP related file.', upload_to='eap/files/', verbose_name='file'), + ), + ] diff --git a/eap/models.py b/eap/models.py index 4523b152d..976fca40a 100644 --- a/eap/models.py +++ b/eap/models.py @@ -208,13 +208,16 @@ class EAPBaseModel(models.Model): class Meta: abstract = True + ordering = ["-created_at"] class EAPFile(EAPBaseModel): - # TODO(susilnem): Make not nullable 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) @@ -459,6 +462,15 @@ class EAPRegistration(EAPBaseModel): 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."), + ) + # Contacts # National Society national_society_contact_name = models.CharField( @@ -490,6 +502,32 @@ class EAPRegistration(EAPBaseModel): 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."), + ) + pfa_signed_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("PFA signed at"), + help_text=_("Timestamp when the PFA was signed."), + ) + activated_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("activated at"), + help_text=_("Timestamp when the EAP was activated."), + ) + # TYPING national_society_id = int country_id = int @@ -497,8 +535,6 @@ class EAPRegistration(EAPBaseModel): id = int class Meta: - # TODO(susilnem): Add ordering when created_at is added to the model. - # ordering = ['-created_at'] verbose_name = _("Development Registration EAP") verbose_name_plural = _("Development Registration EAPs") diff --git a/eap/permissions.py b/eap/permissions.py new file mode 100644 index 000000000..7d45668e8 --- /dev/null +++ b/eap/permissions.py @@ -0,0 +1,65 @@ +from django.contrib.auth.models import Permission +from rest_framework.permissions import BasePermission + +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) + ] + # TODO(susilnem): Add region admin check if needed in future + return national_society_id in country_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") + return has_country_permission(user=user, national_society_id=national_society_id) + + +class EAPBasePermission(BasePermission): + message = "You don't have permission to create/update EAP" + + def has_permission(self, request, view) -> bool: + if request.method not in ["PUT", "PATCH", "POST"]: + return True + + user = request.user + eap_registration = EAPRegistration.objects.filter(id=request.data.get("eap_registration")).first() + + if not eap_registration: + return False + + national_society_id = eap_registration.national_society_id + + return has_country_permission( + user=user, + national_society_id=national_society_id, + ) + + +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 index 4888a807d..c1afff006 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,6 +1,7 @@ import typing from django.contrib.auth.models import User +from django.utils import timezone from django.utils.translation import gettext from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -113,6 +114,28 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any 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"]) + return validated_data + + class EAPFileInputSerializer(serializers.Serializer): file = serializers.ListField(child=serializers.FileField(required=True)) @@ -245,10 +268,10 @@ def create(self, validated_data: dict[str, typing.Any]): (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.NS_ADDRESSING_COMMENTS, EAPRegistration.Status.TECHNICALLY_VALIDATED), (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.TECHNICALLY_VALIDATED), (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.APPROVED), - (EAPRegistration.Status.APPROVED, EAPRegistration.Status.ACTIVATED), + (EAPRegistration.Status.APPROVED, EAPRegistration.Status.PFA_SIGNED), + (EAPRegistration.Status.PFA_SIGNED, EAPRegistration.Status.ACTIVATED), ] ) @@ -297,12 +320,28 @@ def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration # "Review checklist file must be uploaded before changing status to %s." # ) % EAPRegistration.Status(new_status).label # ) + 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): + 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 ) @@ -324,12 +363,41 @@ def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) - # TODO(susilnem): Check if validated budget file has been uploaded. - # 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 - # ) + 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.approved_at = timezone.now() + self.instance.save( + update_fields=[ + "approved_at", + ] + ) + + elif (current_status, new_status) == ( + EAPRegistration.Status.APPROVED, + EAPRegistration.Status.PFA_SIGNED, + ): + # Update timestamp + self.instance.pfa_signed_at = timezone.now() + self.instance.save( + update_fields=[ + "pfa_signed_at", + ] + ) + + elif (current_status, new_status) == ( + EAPRegistration.Status.PFA_SIGNED, + EAPRegistration.Status.ACTIVATED, + ): + # Update timestamp + self.instance.activated_at = timezone.now() + self.instance.save( + update_fields=[ + "activated_at", + ] + ) return new_status diff --git a/eap/test_views.py b/eap/test_views.py index 6af04454b..51f43ba33 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -84,14 +84,25 @@ def setUp(self): self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + # 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.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, ) url = "/api/v2/eap-registration/" self.authenticate() @@ -110,7 +121,7 @@ def test_create_eap_registration(self): "partners": [self.partner1.id, self.partner2.id], } - self.authenticate() + self.authenticate(self.country_admin) response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, 201) @@ -118,7 +129,7 @@ def test_create_eap_registration(self): self.assertIsNotNone(response.data["created_by_details"]) self.assertEqual( response.data["created_by_details"]["id"], - self.user.id, + self.country_admin.id, ) self.assertEqual( { @@ -141,12 +152,12 @@ def test_retrieve_eap_registration(self): 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, + created_by=self.country_admin, + modified_by=self.country_admin, ) url = f"/api/v2/eap-registration/{eap_registration.id}/" - self.authenticate() + self.authenticate(self.country_admin) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["id"], eap_registration.id) @@ -157,8 +168,8 @@ def test_update_eap_registration(self): national_society=self.national_society, disaster_type=self.disaster_type, partners=[self.partner1.id], - created_by=self.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, ) url = f"/api/v2/eap-registration/{eap_registration.id}/" @@ -195,8 +206,8 @@ def test_update_eap_registration(self): # Check cannot update EAP Registration once application is being created SimplifiedEAPFactory.create( eap_registration=eap_registration, - created_by=self.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, ) data_update = { @@ -222,6 +233,17 @@ def setUp(self): self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + # 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, @@ -230,15 +252,15 @@ def test_list_simplified_eap(self): 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, + created_by=self.country_admin, + modified_by=self.country_admin, ) for eap in eap_registrations: SimplifiedEAPFactory.create( eap_registration=eap, - created_by=self.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, ) url = "/api/v2/simplified-eap/" @@ -255,8 +277,8 @@ def test_create_simplified_eap(self): 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, + created_by=self.country_admin, + modified_by=self.country_admin, ) data = { "eap_registration": eap_registration.id, @@ -326,7 +348,7 @@ def test_create_simplified_eap(self): ], } - self.authenticate() + self.authenticate(self.country_admin) response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, 201) @@ -350,8 +372,8 @@ def test_update_simplified_eap(self): 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, + created_by=self.country_admin, + modified_by=self.country_admin, ) enable_approach_readiness_operation_activity_1 = OperationActivityFactory.create( activity="Readiness Activity 1", @@ -456,8 +478,8 @@ def test_update_simplified_eap(self): simplified_eap = SimplifiedEAPFactory.create( eap_registration=eap_registration, - created_by=self.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, enable_approaches=[enable_approach.id], planned_operations=[planned_operation.id], ) @@ -830,8 +852,8 @@ def test_status_transition(self): SimplifiedEAPFactory.create( eap_registration=self.eap_registration, - created_by=self.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, ) # SUCCESS: As Simplified EAP exists @@ -856,8 +878,20 @@ def test_status_transition(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + # NOTE: Transition to UNDER_REVIEW + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + data = { + "status": EAPStatus.UNDER_REVIEW, + } + + # 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.UNDER_REVIEW) + # NOTE: Transition to TECHNICALLY_VALIDATED - # NS_ADDRESSING_COMMENTS -> TECHNICALLY_VALIDATED + # UNDER_REVIEW -> TECHNICALLY_VALIDATED data = { "status": EAPStatus.TECHNICALLY_VALIDATED, } @@ -874,6 +908,10 @@ def test_status_transition(self): 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, + ) # NOTE: Transition to APPROVED # TECHNICALLY_VALIDATED -> APPROVED @@ -887,15 +925,58 @@ def test_status_transition(self): response = self.client.post(self.url, data, format="json") self.assertEqual(response.status_code, 400) + # NOTE: Upload Validated budget file + path = os.path.join(settings.TEST_DIR, "documents") + validated_budget_file = os.path.join(path, "go.png") + url = f"/api/v2/eap-registration/{self.eap_registration.id}/upload-validated-budget-file/" + file_data = { + "validated_budget_file": open(validated_budget_file, "rb"), + } + self.authenticate(self.ifrc_admin_user) + response = self.client.post(url, file_data, format="multipart") + self.assert_200(response) + + 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.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 approved timeline is added + self.eap_registration.refresh_from_db() + self.assertIsNotNone(self.eap_registration.approved_at) + + # NOTE: Transition to PFA_SIGNED + # APPROVED -> PFA_SIGNED + data = { + "status": EAPStatus.PFA_SIGNED, + } + + # 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.PFA_SIGNED) + # Check is the pfa_signed timeline is added + self.eap_registration.refresh_from_db() + self.assertIsNotNone(self.eap_registration.pfa_signed_at) # NOTE: Transition to ACTIVATED - # APPROVED -> ACTIVATED + # PFA_SIGNED -> ACTIVATED data = { "status": EAPStatus.ACTIVATED, } @@ -908,7 +989,11 @@ def test_status_transition(self): # 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) diff --git a/eap/utils.py b/eap/utils.py index 6f7db1a58..bfd948c47 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,5 +1,4 @@ -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User def has_country_permission(user: User, country_id: int) -> bool: diff --git a/eap/views.py b/eap/views.py index 6a22e590f..88b2b6183 100644 --- a/eap/views.py +++ b/eap/views.py @@ -6,11 +6,17 @@ from eap.filter_set import EAPRegistrationFilterSet, SimplifiedEAPFilterSet from eap.models import EAPFile, EAPRegistration, SimplifiedEAP +from eap.permissions import ( + EAPBasePermission, + EAPRegistrationPermissions, + EAPValidatedBudgetPermission, +) from eap.serializers import ( EAPFileInputSerializer, EAPFileSerializer, EAPRegistrationSerializer, EAPStatusSerializer, + EAPValidatedBudgetFileSerializer, SimplifiedEAPSerializer, ) from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission @@ -30,7 +36,7 @@ class EAPRegistrationViewSet(EAPModelViewSet): queryset = EAPRegistration.objects.all() lookup_field = "id" serializer_class = EAPRegistrationSerializer - permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission, EAPRegistrationPermissions] filterset_class = EAPRegistrationFilterSet def get_queryset(self) -> QuerySet[EAPRegistration]: @@ -71,13 +77,34 @@ def update_status( 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] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission, EAPBasePermission] def get_queryset(self) -> QuerySet[SimplifiedEAP]: return ( From 7fd17a1f20a9bfae7aea5c74f8482f79ab7166d7 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 14 Nov 2025 17:11:32 +0545 Subject: [PATCH 14/57] feat(eap): Upload review checklist and active-eap endpoint - Add file validation checks - Add status transition file checks --- ...stration_review_checklist_file_and_more.py | 24 ++++++ eap/models.py | 18 +++- eap/serializers.py | 84 +++++++++++++++---- eap/test_views.py | 19 ++++- eap/utils.py | 17 ++++ eap/views.py | 32 ++++++- main/urls.py | 1 + 7 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 eap/migrations/0006_eapregistration_review_checklist_file_and_more.py diff --git a/eap/migrations/0006_eapregistration_review_checklist_file_and_more.py b/eap/migrations/0006_eapregistration_review_checklist_file_and_more.py new file mode 100644 index 000000000..beae0058a --- /dev/null +++ b/eap/migrations/0006_eapregistration_review_checklist_file_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.19 on 2025-11-14 10:27 + +from django.db import migrations +import main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0005_eapregistration_activated_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='eapregistration', + name='review_checklist_file', + field=main.fields.SecureFileField(blank=True, null=True, upload_to='eap/files/', verbose_name='Review Checklist File'), + ), + migrations.AddField( + model_name='simplifiedeap', + name='updated_checklist_file', + field=main.fields.SecureFileField(blank=True, null=True, upload_to='eap/files/', verbose_name='Updated Checklist File'), + ), + ] diff --git a/eap/models.py b/eap/models.py index 976fca40a..cd87291a6 100644 --- a/eap/models.py +++ b/eap/models.py @@ -377,7 +377,7 @@ class EAPStatus(models.IntegerChoices): """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.""" + """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. @@ -471,6 +471,14 @@ class EAPRegistration(EAPBaseModel): help_text=_("Upload the validated budget file once the EAP is technically validated."), ) + # Review checklist + review_checklist_file = SecureFileField( + verbose_name=_("Review Checklist File"), + upload_to="eap/files/", + null=True, + blank=True, + ) + # Contacts # National Society national_society_contact_name = models.CharField( @@ -865,6 +873,14 @@ class SimplifiedEAP(EAPBaseModel): blank=True, ) + # Review Checklist + updated_checklist_file = SecureFileField( + verbose_name=_("Updated Checklist File"), + upload_to="eap/files/", + null=True, + blank=True, + ) + # TYPING eap_registration_id: int id = int diff --git a/eap/serializers.py b/eap/serializers.py index c1afff006..835028c2d 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -21,10 +21,16 @@ PlannedOperation, SimplifiedEAP, ) -from eap.utils import has_country_permission, is_user_ifrc_admin +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"] + class BaseEAPSerializer(serializers.ModelSerializer): def get_fields(self): @@ -78,6 +84,31 @@ class Meta: ] +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", + ] + + class EAPRegistrationSerializer( NestedUpdateMixin, NestedCreateMixin, @@ -102,6 +133,8 @@ class Meta: read_only_fields = [ "is_active", "status", + "validated_budget_file", + "review_checklist_file", "modified_at", "created_by", "modified_by", @@ -227,6 +260,9 @@ class SimplifiedEAPSerializer( class Meta: model = SimplifiedEAP fields = "__all__" + read_only_fields = [ + "updated_checklist_file", + ] def validate_hazard_impact_file(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: @@ -278,6 +314,8 @@ def create(self, validated_data: dict[str, typing.Any]): 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 @@ -285,9 +323,10 @@ class Meta: "id", "status_display", "status", + "review_checklist_file", ] - def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration.Status: + 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." if not self.instance.has_eap_application: @@ -295,6 +334,7 @@ def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration 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 @@ -313,13 +353,12 @@ def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) - # TODO(susilnem): Check if review checklist has been uploaded. - # if not self.instance.review_checklist_file: - # raise serializers.ValidationError( - # gettext( - # "Review checklist file must be uploaded before changing status to %s." - # ) % EAPRegistration.Status(new_status).label - # ) + if not validated_data.get("review_checklist_file"): + raise serializers.ValidationError( + gettext("Review checklist file must be uploaded before changing status to %s.") + % EAPRegistration.Status(new_status).label + ) + elif (current_status, new_status) == ( EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.TECHNICALLY_VALIDATED, @@ -346,13 +385,11 @@ def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) - # TODO(susilnem): Check if NS Addressing Comments file has been uploaded. - # if not self.instance.ns_addressing_comments_file: - # raise serializers.ValidationError( - # gettext( - # "NS Addressing Comments file must be uploaded before changing status to %s." - # ) % EAPRegistration.Status(new_status).label - # ) + if not (self.instance.simplified_eap or self.instance.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 + ) elif (current_status, new_status) == ( EAPRegistration.Status.TECHNICALLY_VALIDATED, @@ -400,4 +437,17 @@ def validate_status(self, new_status: EAPRegistration.Status) -> EAPRegistration "activated_at", ] ) - return new_status + 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 diff --git a/eap/test_views.py b/eap/test_views.py index 51f43ba33..7d48c63ca 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1,4 +1,5 @@ import os +import tempfile from django.conf import settings from django.contrib.auth.models import Group, Permission @@ -872,11 +873,23 @@ def test_status_transition(self): self.assertEqual(response.status_code, 400) # NOTE: Login as IFRC admin user - # SUCCESS: As only ifrc admins or superuser can + + # 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, 200) - self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + self.assertEqual(response.status_code, 400) + + # 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) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) # NOTE: Transition to UNDER_REVIEW # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW diff --git a/eap/utils.py b/eap/utils.py index bfd948c47..a2a030517 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,4 +1,7 @@ +import os + from django.contrib.auth.models import Permission, User +from django.core.exceptions import ValidationError def has_country_permission(user: User, country_id: int) -> bool: @@ -24,3 +27,17 @@ def is_user_ifrc_admin(user: User) -> bool: 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") diff --git a/eap/views.py b/eap/views.py index 88b2b6183..5b22f2bf3 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,11 +1,12 @@ # Create your views here. +from django.db.models import Case, F, IntegerField, Value, When from django.db.models.query import QuerySet from drf_spectacular.utils import extend_schema from rest_framework import mixins, permissions, response, status, viewsets from rest_framework.decorators import action from eap.filter_set import EAPRegistrationFilterSet, SimplifiedEAPFilterSet -from eap.models import EAPFile, EAPRegistration, SimplifiedEAP +from eap.models import EAPFile, EAPRegistration, EAPStatus, EAPType, SimplifiedEAP from eap.permissions import ( EAPBasePermission, EAPRegistrationPermissions, @@ -17,6 +18,7 @@ EAPRegistrationSerializer, EAPStatusSerializer, EAPValidatedBudgetFileSerializer, + MiniEAPSerializer, SimplifiedEAPSerializer, ) from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission @@ -32,6 +34,34 @@ class EAPModelViewSet( 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( + # TODO(susilnem): Verify the requirements(CHF) field map + When(eap_type=EAPType.SIMPLIFIED_EAP, then=F("simplified_eap__total_budget")), + # When(eap_type=EAPType.FULL_EAP, then=F('full_eap__total_budget')), + default=Value(0), + output_field=IntegerField(), + ) + ) + ) + + class EAPRegistrationViewSet(EAPModelViewSet): queryset = EAPRegistration.objects.all() lookup_field = "id" diff --git a/main/urls.py b/main/urls.py index f526b74d4..7cd4ddef5 100644 --- a/main/urls.py +++ b/main/urls.py @@ -194,6 +194,7 @@ 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"eap-file", eap_views.EAPFileViewSet, basename="eap_file") From 8f8ee27d3efbf40a63a3cdea19c7ba693e1e070f Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 19 Nov 2025 16:33:33 +0545 Subject: [PATCH 15/57] feat(eap): Add snapshot feature on simplified eap --- ...is_locked_simplifiedeap_parent_and_more.py | 29 +++++ eap/models.py | 109 ++++++++++++++++++ eap/serializers.py | 6 + 3 files changed, 144 insertions(+) create mode 100644 eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py diff --git a/eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py b/eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py new file mode 100644 index 000000000..910dbb4b2 --- /dev/null +++ b/eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.19 on 2025-11-19 10:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0006_eapregistration_review_checklist_file_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='simplifiedeap', + name='is_locked', + field=models.BooleanField(default=False, help_text='Indicates whether the Simplified EAP is locked for editing.', verbose_name='Is Locked?'), + ), + migrations.AddField( + model_name='simplifiedeap', + name='parent', + field=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'), + ), + migrations.AddField( + model_name='simplifiedeap', + name='version', + field=models.IntegerField(default=1, help_text='Version identifier for the Simplified EAP.', verbose_name='Version'), + ), + ] diff --git a/eap/models.py b/eap/models.py index cd87291a6..bc6586cf7 100644 --- a/eap/models.py +++ b/eap/models.py @@ -881,8 +881,30 @@ class SimplifiedEAP(EAPBaseModel): blank=True, ) + # 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 eap_registration_id: int + parent_id: int id = int class Meta: @@ -891,3 +913,90 @@ class Meta: def __str__(self): return f"Simplified EAP for {self.eap_registration}" + + def generate_snapshot(self): + """Generate a snapshot of the given Simplified EAP. + + Returns: + SimplifiedEAPSnapshot: The created snapshot instance. + """ + + snapshot = SimplifiedEAP.objects.create( + # Meta data + parent_id=self.id, + version=self.version + 1, + created_by=self.created_by, + modified_by=self.modified_by, + # Raw data + eap_registration_id=self.eap_registration_id, + cover_image_id=self.cover_image if self.cover_image else None, + seap_timeframe=self.seap_timeframe, + # Contacts + national_society_contact_name=self.national_society_contact_name, + national_society_contact_title=self.national_society_contact_title, + national_society_contact_email=self.national_society_contact_email, + national_society_contact_phone_number=self.national_society_contact_phone_number, + partner_ns_name=self.partner_ns_name, + partner_ns_email=self.partner_ns_email, + partner_ns_title=self.partner_ns_title, + partner_ns_phone_number=self.partner_ns_phone_number, + ifrc_delegation_focal_point_name=self.ifrc_delegation_focal_point_name, + ifrc_delegation_focal_point_email=self.ifrc_delegation_focal_point_email, + ifrc_delegation_focal_point_title=self.ifrc_delegation_focal_point_title, + ifrc_delegation_focal_point_phone_number=self.ifrc_delegation_focal_point_phone_number, + ifrc_head_of_delegation_name=self.ifrc_head_of_delegation_name, + ifrc_head_of_delegation_email=self.ifrc_head_of_delegation_email, + ifrc_head_of_delegation_title=self.ifrc_head_of_delegation_title, + ifrc_head_of_delegation_phone_number=self.ifrc_head_of_delegation_phone_number, + dref_focal_point_name=self.dref_focal_point_name, + dref_focal_point_email=self.dref_focal_point_email, + dref_focal_point_title=self.dref_focal_point_title, + dref_focal_point_phone_number=self.dref_focal_point_phone_number, + ifrc_regional_focal_point_name=self.ifrc_regional_focal_point_name, + ifrc_regional_focal_point_email=self.ifrc_regional_focal_point_email, + ifrc_regional_focal_point_title=self.ifrc_regional_focal_point_title, + ifrc_regional_focal_point_phone_number=self.ifrc_regional_focal_point_phone_number, + ifrc_regional_ops_manager_name=self.ifrc_regional_ops_manager_name, + ifrc_regional_ops_manager_email=self.ifrc_regional_ops_manager_email, + ifrc_regional_ops_manager_title=self.ifrc_regional_ops_manager_title, + ifrc_regional_ops_manager_phone_number=self.ifrc_regional_ops_manager_phone_number, + ifrc_regional_head_dcc_name=self.ifrc_regional_head_dcc_name, + ifrc_regional_head_dcc_email=self.ifrc_regional_head_dcc_email, + ifrc_regional_head_dcc_title=self.ifrc_regional_head_dcc_title, + ifrc_regional_head_dcc_phone_number=self.ifrc_regional_head_dcc_phone_number, + ifrc_global_ops_coordinator_name=self.ifrc_global_ops_coordinator_name, + ifrc_global_ops_coordinator_email=self.ifrc_global_ops_coordinator_email, + ifrc_global_ops_coordinator_title=self.ifrc_global_ops_coordinator_title, + ifrc_global_ops_coordinator_phone_number=self.ifrc_global_ops_coordinator_phone_number, + prioritized_hazard_and_impact=self.prioritized_hazard_and_impact, + risks_selected_protocols=self.risks_selected_protocols, + selected_early_actions=self.selected_early_actions, + overall_objective_intervention=self.overall_objective_intervention, + potential_geographical_high_risk_areas=self.potential_geographical_high_risk_areas, + people_targeted=self.people_targeted, + assisted_through_operation=self.assisted_through_operation, + selection_criteria=self.selection_criteria, + trigger_statement=self.trigger_statement, + seap_lead_time=self.seap_lead_time, + operational_timeframe=self.operational_timeframe, + trigger_threshold_justification=self.trigger_threshold_justification, + next_step_towards_full_eap=self.next_step_towards_full_eap, + early_action_capability=self.early_action_capability, + rcrc_movement_involvement=self.rcrc_movement_involvement, + total_budget=self.total_budget, + readiness_budget=self.readiness_budget, + pre_positioning_budget=self.pre_positioning_budget, + early_action_budget=self.early_action_budget, + budget_file=self.budget_file, + ) + # TODO(susilnem): DeepCopy M2M relationships + snapshot.hazard_impact_file.add(*self.hazard_impact_file.all()) + snapshot.risk_selected_protocols_file.add(*self.risk_selected_protocols_file.all()) + snapshot.selected_early_actions_file.add(*self.selected_early_actions_file.all()) + snapshot.admin2.add(*self.admin2.all()) + snapshot.planned_operations.add(*self.planned_operations.all()) + snapshot.enable_approaches.add(*self.enable_approaches.all()) + # Setting Parent as locked + self.is_locked = True + self.save(update_fields=["is_locked"]) + return snapshot diff --git a/eap/serializers.py b/eap/serializers.py index 835028c2d..b1c464ccb 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -262,6 +262,7 @@ class Meta: fields = "__all__" read_only_fields = [ "updated_checklist_file", + "version", ] def validate_hazard_impact_file(self, images): @@ -359,6 +360,11 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) + # NOTE: Add checks for FULL EAP + simplified_eap_instance: SimplifiedEAP | None = self.instance.simplified_eap + if simplified_eap_instance: + simplified_eap_instance.generate_snapshot() + elif (current_status, new_status) == ( EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.TECHNICALLY_VALIDATED, From fdb87260d63ecf3343010a13777cb68ea5a12454 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 20 Nov 2025 14:27:38 +0545 Subject: [PATCH 16/57] feat(eap): Add snapshot feature and validation checks on status update - Optimise and add fields on admin panel - Update test cases for snapshot - Add utility function to clone the related model --- eap/admin.py | 10 +- ..._alter_eapregistration_options_and_more.py | 78 +++++++++ ...is_locked_simplifiedeap_parent_and_more.py | 29 ---- eap/models.py | 125 ++++---------- eap/serializers.py | 38 +++- eap/test_views.py | 163 ++++++++++++++++-- eap/utils.py | 60 +++++++ eap/views.py | 14 +- 8 files changed, 380 insertions(+), 137 deletions(-) create mode 100644 eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py delete mode 100644 eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py diff --git a/eap/admin.py b/eap/admin.py index c18b5192c..6771eca07 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -55,7 +55,7 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): "eap_registration__country__name", "eap_registration__disaster_type__name", ) - list_display = ("eap_registration",) + list_display = ("simplifed_eap_application", "version", "is_locked") autocomplete_fields = ( "eap_registration", "created_by", @@ -69,8 +69,16 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): "selected_early_actions_file", "planned_operations", "enable_approaches", + "parent", + "is_locked", + "version", ) + 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() diff --git a/eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py b/eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py new file mode 100644 index 000000000..4256bc9ed --- /dev/null +++ b/eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 4.2.19 on 2025-11-20 07:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0006_eapregistration_review_checklist_file_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="eapfile", + options={ + "ordering": ["-id"], + "verbose_name": "eap file", + "verbose_name_plural": "eap files", + }, + ), + migrations.AlterModelOptions( + name="eapregistration", + options={ + "ordering": ["-id"], + "verbose_name": "Development Registration EAP", + "verbose_name_plural": "Development Registration EAPs", + }, + ), + migrations.AlterModelOptions( + name="simplifiedeap", + options={ + "ordering": ["-id"], + "verbose_name": "Simplified EAP", + "verbose_name_plural": "Simplified EAPs", + }, + ), + migrations.AddField( + model_name="simplifiedeap", + name="is_locked", + field=models.BooleanField( + default=False, + help_text="Indicates whether the Simplified EAP is locked for editing.", + verbose_name="Is Locked?", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="parent", + field=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", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="version", + field=models.IntegerField( + default=1, + help_text="Version identifier for the Simplified EAP.", + verbose_name="Version", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="eap_registration", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="simplified_eap", + to="eap.eapregistration", + verbose_name="EAP Development Registration", + ), + ), + ] diff --git a/eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py b/eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py deleted file mode 100644 index 910dbb4b2..000000000 --- a/eap/migrations/0007_simplifiedeap_is_locked_simplifiedeap_parent_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-19 10:45 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('eap', '0006_eapregistration_review_checklist_file_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='simplifiedeap', - name='is_locked', - field=models.BooleanField(default=False, help_text='Indicates whether the Simplified EAP is locked for editing.', verbose_name='Is Locked?'), - ), - migrations.AddField( - model_name='simplifiedeap', - name='parent', - field=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'), - ), - migrations.AddField( - model_name='simplifiedeap', - name='version', - field=models.IntegerField(default=1, help_text='Version identifier for the Simplified EAP.', verbose_name='Version'), - ), - ] diff --git a/eap/models.py b/eap/models.py index bc6586cf7..d5e858229 100644 --- a/eap/models.py +++ b/eap/models.py @@ -1,6 +1,6 @@ from django.conf import settings from django.contrib.postgres.fields import ArrayField -from django.db import models +from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from api.models import Admin2, Country, DisasterType, District @@ -206,9 +206,14 @@ class EAPBaseModel(models.Model): related_name="%(class)s_modified_by", ) + # TYPING + id: int + created_by_id: int + modified_by_id: int + class Meta: abstract = True - ordering = ["-created_at"] + ordering = ["-id"] class EAPFile(EAPBaseModel): @@ -224,6 +229,7 @@ class EAPFile(EAPBaseModel): class Meta: verbose_name = _("eap file") verbose_name_plural = _("eap files") + ordering = ["-id"] class OperationActivity(models.Model): @@ -545,6 +551,7 @@ class EAPRegistration(EAPBaseModel): 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 @@ -554,7 +561,9 @@ def __str__(self): def has_eap_application(self) -> bool: """Check if the EAP Registration has an associated EAP application.""" # TODO(susilnem): Add FULL EAP check, when model is created. - return hasattr(self, "simplified_eap") + if hasattr(self, "simplified_eap") and self.simplified_eap.exists(): + return True + return False @property def get_status_enum(self) -> EAPStatus: @@ -582,7 +591,7 @@ def update_eap_type(self, eap_type: EAPType, commit: bool = True): class SimplifiedEAP(EAPBaseModel): """Model representing a Simplified EAP.""" - eap_registration = models.OneToOneField[EAPRegistration, EAPRegistration]( + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( EAPRegistration, on_delete=models.CASCADE, verbose_name=_("EAP Development Registration"), @@ -910,93 +919,33 @@ class SimplifiedEAP(EAPBaseModel): class Meta: verbose_name = _("Simplified EAP") verbose_name_plural = _("Simplified EAPs") + ordering = ["-id"] def __str__(self): - return f"Simplified EAP for {self.eap_registration}" + return f"Simplified EAP for {self.eap_registration}- version:{self.version}" def generate_snapshot(self): - """Generate a snapshot of the given Simplified EAP. - - Returns: - SimplifiedEAPSnapshot: The created snapshot instance. + """ + Generate a snapshot of the given Simplified EAP. """ - snapshot = SimplifiedEAP.objects.create( - # Meta data - parent_id=self.id, - version=self.version + 1, - created_by=self.created_by, - modified_by=self.modified_by, - # Raw data - eap_registration_id=self.eap_registration_id, - cover_image_id=self.cover_image if self.cover_image else None, - seap_timeframe=self.seap_timeframe, - # Contacts - national_society_contact_name=self.national_society_contact_name, - national_society_contact_title=self.national_society_contact_title, - national_society_contact_email=self.national_society_contact_email, - national_society_contact_phone_number=self.national_society_contact_phone_number, - partner_ns_name=self.partner_ns_name, - partner_ns_email=self.partner_ns_email, - partner_ns_title=self.partner_ns_title, - partner_ns_phone_number=self.partner_ns_phone_number, - ifrc_delegation_focal_point_name=self.ifrc_delegation_focal_point_name, - ifrc_delegation_focal_point_email=self.ifrc_delegation_focal_point_email, - ifrc_delegation_focal_point_title=self.ifrc_delegation_focal_point_title, - ifrc_delegation_focal_point_phone_number=self.ifrc_delegation_focal_point_phone_number, - ifrc_head_of_delegation_name=self.ifrc_head_of_delegation_name, - ifrc_head_of_delegation_email=self.ifrc_head_of_delegation_email, - ifrc_head_of_delegation_title=self.ifrc_head_of_delegation_title, - ifrc_head_of_delegation_phone_number=self.ifrc_head_of_delegation_phone_number, - dref_focal_point_name=self.dref_focal_point_name, - dref_focal_point_email=self.dref_focal_point_email, - dref_focal_point_title=self.dref_focal_point_title, - dref_focal_point_phone_number=self.dref_focal_point_phone_number, - ifrc_regional_focal_point_name=self.ifrc_regional_focal_point_name, - ifrc_regional_focal_point_email=self.ifrc_regional_focal_point_email, - ifrc_regional_focal_point_title=self.ifrc_regional_focal_point_title, - ifrc_regional_focal_point_phone_number=self.ifrc_regional_focal_point_phone_number, - ifrc_regional_ops_manager_name=self.ifrc_regional_ops_manager_name, - ifrc_regional_ops_manager_email=self.ifrc_regional_ops_manager_email, - ifrc_regional_ops_manager_title=self.ifrc_regional_ops_manager_title, - ifrc_regional_ops_manager_phone_number=self.ifrc_regional_ops_manager_phone_number, - ifrc_regional_head_dcc_name=self.ifrc_regional_head_dcc_name, - ifrc_regional_head_dcc_email=self.ifrc_regional_head_dcc_email, - ifrc_regional_head_dcc_title=self.ifrc_regional_head_dcc_title, - ifrc_regional_head_dcc_phone_number=self.ifrc_regional_head_dcc_phone_number, - ifrc_global_ops_coordinator_name=self.ifrc_global_ops_coordinator_name, - ifrc_global_ops_coordinator_email=self.ifrc_global_ops_coordinator_email, - ifrc_global_ops_coordinator_title=self.ifrc_global_ops_coordinator_title, - ifrc_global_ops_coordinator_phone_number=self.ifrc_global_ops_coordinator_phone_number, - prioritized_hazard_and_impact=self.prioritized_hazard_and_impact, - risks_selected_protocols=self.risks_selected_protocols, - selected_early_actions=self.selected_early_actions, - overall_objective_intervention=self.overall_objective_intervention, - potential_geographical_high_risk_areas=self.potential_geographical_high_risk_areas, - people_targeted=self.people_targeted, - assisted_through_operation=self.assisted_through_operation, - selection_criteria=self.selection_criteria, - trigger_statement=self.trigger_statement, - seap_lead_time=self.seap_lead_time, - operational_timeframe=self.operational_timeframe, - trigger_threshold_justification=self.trigger_threshold_justification, - next_step_towards_full_eap=self.next_step_towards_full_eap, - early_action_capability=self.early_action_capability, - rcrc_movement_involvement=self.rcrc_movement_involvement, - total_budget=self.total_budget, - readiness_budget=self.readiness_budget, - pre_positioning_budget=self.pre_positioning_budget, - early_action_budget=self.early_action_budget, - budget_file=self.budget_file, - ) - # TODO(susilnem): DeepCopy M2M relationships - snapshot.hazard_impact_file.add(*self.hazard_impact_file.all()) - snapshot.risk_selected_protocols_file.add(*self.risk_selected_protocols_file.all()) - snapshot.selected_early_actions_file.add(*self.selected_early_actions_file.all()) - snapshot.admin2.add(*self.admin2.all()) - snapshot.planned_operations.add(*self.planned_operations.all()) - snapshot.enable_approaches.add(*self.enable_approaches.all()) - # Setting Parent as locked - self.is_locked = True - self.save(update_fields=["is_locked"]) - return snapshot + from eap.utils import copy_model_instance + + with transaction.atomic(): + 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, + "updated_checklist_file": None, + }, + exclude_clone_m2m_fields=[ + "admin2", + ], + ) + + # Setting Parent as locked + self.is_locked = True + self.save(update_fields=["is_locked"]) diff --git a/eap/serializers.py b/eap/serializers.py index b1c464ccb..8faaf1553 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -81,6 +81,9 @@ class Meta: "early_action_budget", "seap_timeframe", "budget_file", + "version", + "is_locked", + "updated_checklist_file", ] @@ -122,7 +125,7 @@ class EAPRegistrationSerializer( disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) # EAPs - simplified_eap_details = MiniSimplifiedEAPSerializer(source="simplified_eap", read_only=True) + simplified_eap_details = MiniSimplifiedEAPSerializer(source="simplified_eap", many=True, read_only=True) # Status status_display = serializers.CharField(source="get_status_display", read_only=True) @@ -131,7 +134,6 @@ class Meta: model = EAPRegistration fields = "__all__" read_only_fields = [ - "is_active", "status", "validated_budget_file", "review_checklist_file", @@ -140,7 +142,7 @@ class Meta: "modified_by", ] - def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]): + def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: # 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.") @@ -166,6 +168,7 @@ def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.An ) validate_file_type(validated_data["validated_budget_file"]) + validate_file_extention(validated_data["validated_budget_file"].name, ALLOWED_FILE_EXTENTIONS) return validated_data @@ -261,27 +264,38 @@ class Meta: model = SimplifiedEAP fields = "__all__" read_only_fields = [ - "updated_checklist_file", "version", + "is_locked", ] def validate_hazard_impact_file(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") + validate_file_type(images) return images def validate_risk_selected_protocols_file(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") + validate_file_type(images) return images def validate_selected_early_actions_file(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") + validate_file_type(images) return images def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: eap_registration: EAPRegistration = data["eap_registration"] + + if not self.instance and eap_registration.has_eap_application: + raise serializers.ValidationError("Simplified EAP for this EAP registration already exists.") + + # NOTE: Cannot update locked Simplified EAP + if self.instance and self.instance.is_locked: + raise serializers.ValidationError("Cannot update locked Simplified EAP.") + 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.") @@ -361,7 +375,10 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ) # NOTE: Add checks for FULL EAP - simplified_eap_instance: SimplifiedEAP | None = self.instance.simplified_eap + simplified_eap_instance: SimplifiedEAP | None = ( + SimplifiedEAP.objects.filter(eap_registration=self.instance).order_by("-version").first() + ) + if simplified_eap_instance: simplified_eap_instance.generate_snapshot() @@ -391,7 +408,16 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) - if not (self.instance.simplified_eap or self.instance.simplified_eap.updated_checklist_file): + latest_simplified_eap: SimplifiedEAP | None = ( + SimplifiedEAP.objects.filter( + eap_registration=self.instance, + ) + .order_by("-version") + .first() + ) + + # TODO(susilnem): Add checks for FULL EAP + if not (latest_simplified_eap and 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 diff --git a/eap/test_views.py b/eap/test_views.py index 7d48c63ca..6da2829bb 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -22,6 +22,7 @@ EnableApproach, OperationActivity, PlannedOperation, + SimplifiedEAP, ) from main.test_case import APITestCase @@ -166,6 +167,7 @@ def test_retrieve_eap_registration(self): 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], @@ -851,7 +853,7 @@ def test_status_transition(self): response = self.client.post(self.url, data, format="json") self.assertEqual(response.status_code, 400) - SimplifiedEAPFactory.create( + simplified_eap = SimplifiedEAPFactory.create( eap_registration=self.eap_registration, created_by=self.country_admin, modified_by=self.country_admin, @@ -873,13 +875,12 @@ def test_status_transition(self): 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 checklist file + # 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") @@ -891,14 +892,155 @@ def test_status_transition(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + self.eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.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.name, + "Latest Snapshot shouldn't have the updated checklist file.", + ) + # 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}/" + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Updated Test content") + tmp_file.seek(0) + + file_data = {"eap_registration": second_snapshot.eap_registration_id, "updated_checklist_file": tmp_file} + + response = self.client.patch(url, file_data, format="multipart") + 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) + + # 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) + 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 2 + 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 2.", + ) + # 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.name, + "Latest snapshot shouldn't have the updated 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/{third_snapshot.id}/" + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Updated Test content") + tmp_file.seek(0) + + file_data = {"eap_registration": third_snapshot.eap_registration_id, "updated_checklist_file": tmp_file} + + response = self.client.patch(url, file_data, format="multipart") + 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) @@ -939,15 +1081,14 @@ def test_status_transition(self): self.assertEqual(response.status_code, 400) # NOTE: Upload Validated budget file - path = os.path.join(settings.TEST_DIR, "documents") - validated_budget_file = os.path.join(path, "go.png") url = f"/api/v2/eap-registration/{self.eap_registration.id}/upload-validated-budget-file/" - file_data = { - "validated_budget_file": open(validated_budget_file, "rb"), - } - self.authenticate(self.ifrc_admin_user) - response = self.client.post(url, file_data, format="multipart") - self.assert_200(response) + 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) self.eap_registration.refresh_from_db() self.assertIsNotNone( diff --git a/eap/utils.py b/eap/utils.py index a2a030517..b294aad82 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,8 +1,11 @@ import os +import typing from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError +from eap.models import SimplifiedEAP + def has_country_permission(user: User, country_id: int) -> bool: """Checks if the user has country admin permission.""" @@ -41,3 +44,60 @@ def validate_file_extention(filename: str, allowed_extensions: list[str]): 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") + + +# TODO(susilnem): Add typing for FullEAP + + +def copy_model_instance( + instance: SimplifiedEAP, + overrides: dict[str, typing.Any] | None = None, + exclude_clone_m2m_fields: list[str] | None = None, +) -> SimplifiedEAP: + """ + Creates a copy of a Django model instance, including its many-to-many relationships. + + Args: + instance: The Django model instance to be copied. + overrides: A dictionary of field names and values to override in the copied instance. + exclude_clone_m2m_fields: A list of many-to-many field names to exclude from copying + + Returns: + A new Django model instance that is a copy of the original, with specified overrides + applied and specified many-to-many relationships excluded. + + """ + + overrides = overrides or {} + exclude_m2m_fields = exclude_clone_m2m_fields or [] + + opts = instance._meta + data = {} + + for field in opts.fields: + if field.auto_created: + continue + data[field.name] = getattr(instance, field.name) + + data[opts.pk.attname] = None + + # NOTE: Apply overrides + data.update(overrides) + + clone_instance = instance.__class__.objects.create(**data) + + for m2m_field in opts.many_to_many: + # NOTE: Exclude specified many-to-many fields from cloning but link to original related instances + if m2m_field.name in exclude_m2m_fields: + related_objects = getattr(instance, m2m_field.name).all() + getattr(clone_instance, m2m_field.name).set(related_objects) + continue + + related_objects = getattr(instance, m2m_field.name).all() + cloned_related = [ + obj.__class__.objects.create(**{f.name: getattr(obj, f.name) for f in obj._meta.fields if not f.auto_created}) + for obj in related_objects + ] + getattr(clone_instance, m2m_field.name).set(cloned_related) + + return clone_instance diff --git a/eap/views.py b/eap/views.py index 5b22f2bf3..077a86f40 100644 --- a/eap/views.py +++ b/eap/views.py @@ -53,8 +53,17 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: .annotate( requirement_cost=Case( # TODO(susilnem): Verify the requirements(CHF) field map - When(eap_type=EAPType.SIMPLIFIED_EAP, then=F("simplified_eap__total_budget")), - # When(eap_type=EAPType.FULL_EAP, then=F('full_eap__total_budget')), + When( + eap_type=EAPType.SIMPLIFIED_EAP, + then=SimplifiedEAP.objects.filter(eap_registration=F("id")) + .order_by("version") + .values("total_budget")[:1], + ), + # TODO(susilnem): Add check for FullEAP + # When( + # eap_type=EAPType.FULL_EAP, + # then=FullEAP.objects.filter(eap_registration=F("id")).order_by("version").values("total_budget")[:1], + # ) default=Value(0), output_field=IntegerField(), ) @@ -84,6 +93,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: "partners", "simplified_eap", ) + .order_by("id") ) @action( From 406724d5d29612150aa90e0da0a2c4f90b892723 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Wed, 19 Nov 2025 10:29:09 +0545 Subject: [PATCH 17/57] feat(eap): add simplified eap to global pdf export --- .../0227_alter_export_export_type.py | 18 ++++++ api/models.py | 1 + api/serializers.py | 6 ++ api/tasks.py | 2 + eap/test_views.py | 63 +++++++++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 api/migrations/0227_alter_export_export_type.py 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..8fb9d801b --- /dev/null +++ b/api/migrations/0227_alter_export_export_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.19 on 2025-11-18 05:22 + +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-eap', 'Simplified EAP')], max_length=255, verbose_name='Export Type'), + ), + ] diff --git a/api/models.py b/api/models.py index 9e00c3aca..23541c9f2 100644 --- a/api/models.py +++ b/api/models.py @@ -2564,6 +2564,7 @@ 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-eap", _("Simplified 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/serializers.py b/api/serializers.py index 8a6b08e91..72c6d8ce4 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -15,6 +15,7 @@ from api.utils import CountryValidator, RegionValidator from deployments.models import EmergencyProject, Personnel, PersonnelDeployment from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate +from eap.models import SimplifiedEAP from lang.models import String from lang.serializers import ModelSerializer from local_units.models import DelegationOffice @@ -2569,6 +2570,11 @@ 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: + simplified_eap = SimplifiedEAP.objects.filter(id=export_id).first() + title = ( + f"{simplified_eap.eap_registration.national_society.name}-{simplified_eap.eap_registration.disaster_type.name}" + ) else: title = "Export" user = self.context["request"].user diff --git a/api/tasks.py b/api/tasks.py index a0126abcc..dded4d66d 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -118,6 +118,8 @@ def generate_url(url, export_id, user, title, language): 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' + elif export.export_type == Export.ExportType.SIMPLIFIED_EAP: + file_name = f'SIMPLIFIED EAP {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( diff --git a/eap/test_views.py b/eap/test_views.py index 6da2829bb..c9be099a8 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1,5 +1,6 @@ import os import tempfile +from unittest import mock from django.conf import settings from django.contrib.auth.models import Group, Permission @@ -7,6 +8,7 @@ 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 ( EAPRegistrationFactory, @@ -1151,3 +1153,64 @@ def test_status_transition(self): # Check is the activated timeline is added self.eap_registration.refresh_from_db() self.assertIsNotNone(self.eap_registration.activated_at) + + +class TestSimplifiedEapPdfExport(APITestCase): + def setUp(self): + super().setUp() + self.country = CountryFactory.create(name="country1", iso3="XXX") + self.national_society = CountryFactory.create(name="national_society1", iso3="YYY") + self.disaster_type = DisasterTypeFactory.create(name="disaster1") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + + self.user = UserFactory.create() + + self.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, + ) + + self.simplified_eap = SimplifiedEAPFactory.create( + eap_registration=self.eap_registration, + created_by=self.user, + modified_by=self.user, + national_society_contact_title="NS Title Example", + ) + self.url = "/api/v2/pdf-export/" + + @mock.patch("api.serializers.generate_url.delay") + def test_create_simplified_eap_export(self, mock_generate_url): + data = { + "export_type": Export.ExportType.SIMPLIFIED_EAP, + "export_id": self.simplified_eap.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) + export = Export.objects.first() + self.assertIsNotNone(export) + + expected_url = ( + f"{settings.GO_WEB_INTERNAL_URL}/" f"{Export.ExportType.SIMPLIFIED_EAP}/" f"{self.simplified_eap.id}/export/" + ) + self.assertEqual(export.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( + export.url, + export.id, + self.user.id, + title, + ) From 21aa7e2544abb7bd4a92ba5bd5576b7c3e4ca2c7 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 25 Nov 2025 11:41:06 +0545 Subject: [PATCH 18/57] feat(eap): Add validation on operation timeframe and time_value - Update test cases - Add timeframe values on enums --- eap/enums.py | 4 ++ eap/factories.py | 3 - eap/models.py | 64 ++++++++++++++++++++ eap/serializers.py | 34 +++++++++++ eap/test_views.py | 143 +++++++++++++++++++++++++++++++-------------- 5 files changed, 202 insertions(+), 46 deletions(-) diff --git a/eap/enums.py b/eap/enums.py index 4204bd845..f8eb9c5c5 100644 --- a/eap/enums.py +++ b/eap/enums.py @@ -5,5 +5,9 @@ "eap_type": models.EAPType, "sector": models.PlannedOperation.Sector, "timeframe": models.OperationActivity.TimeFrame, + "years_timeframe_value": models.OperationActivity.YearsTimeFrameChoices, + "months_timeframe_value": models.OperationActivity.MonthsTimeFrameChoices, + "days_timeframe_value": models.OperationActivity.DaysTimeFrameChoices, + "hours_timeframe_value": models.OperationActivity.HoursTimeFrameChoices, "approach": models.EnableApproach.Approach, } diff --git a/eap/factories.py b/eap/factories.py index 33631accc..c1d2f3c3d 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -1,5 +1,3 @@ -from random import random - import factory from factory import fuzzy @@ -66,7 +64,6 @@ class Meta: activity = fuzzy.FuzzyText(length=50, prefix="Activity-") timeframe = fuzzy.FuzzyChoice(OperationActivity.TimeFrame) - time_value = factory.LazyFunction(lambda: [random.randint(1, 12) for _ in range(3)]) class EnableApproachFactory(factory.django.DjangoModelFactory): diff --git a/eap/models.py b/eap/models.py index d5e858229..b273ab785 100644 --- a/eap/models.py +++ b/eap/models.py @@ -233,12 +233,76 @@ class Meta: class OperationActivity(models.Model): + # NOTE: `timeframe` and `time_value` together represent the time span for an activity. + # Make sure to keep them in sync. 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") + activity = models.CharField(max_length=255, verbose_name=_("Activity")) timeframe = models.IntegerField(choices=TimeFrame.choices, verbose_name=_("Timeframe")) time_value = ArrayField( diff --git a/eap/serializers.py b/eap/serializers.py index 8faaf1553..775bda63e 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -193,15 +193,49 @@ def validate_file(self, file): return file +ALLOWED_MAP_TIMEFRAMES_VALUE = { + OperationActivity.TimeFrame.YEARS: list(OperationActivity.YearsTimeFrameChoices.values), + OperationActivity.TimeFrame.MONTHS: list(OperationActivity.MonthsTimeFrameChoices.values), + OperationActivity.TimeFrame.DAYS: list(OperationActivity.DaysTimeFrameChoices.values), + OperationActivity.TimeFrame.HOURS: list(OperationActivity.HoursTimeFrameChoices.values), +} + + class OperationActivitySerializer( serializers.ModelSerializer, ): id = serializers.IntegerField(required=False) + timeframe = serializers.ChoiceField( + choices=OperationActivity.TimeFrame.choices, + required=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, OperationActivity.TimeFrame(timeframe).label) + } + ) + return validated_data + class PlannedOperationSerializer( NestedUpdateMixin, diff --git a/eap/test_views.py b/eap/test_views.py index c9be099a8..22d9b6277 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -79,10 +79,10 @@ def test_upload_invalid_files(self): class EAPRegistrationTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="XXX") + self.country = CountryFactory.create(name="country1", iso3="EAP") self.national_society = CountryFactory.create( name="national_society1", - iso3="YYY", + iso3="NSC", ) self.disaster_type = DisasterTypeFactory.create(name="disaster1") self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") @@ -229,10 +229,10 @@ def test_update_eap_registration(self): class EAPSimplifiedTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="XXX") + self.country = CountryFactory.create(name="country1", iso3="EAP") self.national_society = CountryFactory.create( name="national_society1", - iso3="YYY", + iso3="NSC", ) self.disaster_type = DisasterTypeFactory.create(name="disaster1") self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") @@ -303,21 +303,27 @@ def test_create_simplified_eap(self): { "activity": "early action activity", "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [2, 3], + "time_value": [ + OperationActivity.YearsTimeFrameChoices.ONE_YEAR, + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + ], } ], "prepositioning_activities": [ { "activity": "prepositioning activity", "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [2, 3], + "time_value": [ + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + ], } ], "readiness_activities": [ { "activity": "readiness activity", "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [2147483647], + "time_value": [OperationActivity.YearsTimeFrameChoices.FIVE_YEARS], } ], } @@ -332,21 +338,27 @@ def test_create_simplified_eap(self): { "activity": "early action activity", "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [2, 3], + "time_value": [ + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + ], } ], "prepositioning_activities": [ { "activity": "prepositioning activity", "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [2, 3], + "time_value": [OperationActivity.YearsTimeFrameChoices.THREE_YEARS], } ], "readiness_activities": [ { "activity": "readiness activity", "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [2147483647], + "time_value": [ + OperationActivity.YearsTimeFrameChoices.FIVE_YEARS, + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + ], } ], }, @@ -383,32 +395,41 @@ def test_update_simplified_eap(self): enable_approach_readiness_operation_activity_1 = OperationActivityFactory.create( activity="Readiness Activity 1", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[1, 2], + time_value=[OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS], ) enable_approach_readiness_operation_activity_2 = OperationActivityFactory.create( activity="Readiness Activity 2", timeframe=OperationActivity.TimeFrame.YEARS, - time_value=[1, 5], + time_value=[OperationActivity.YearsTimeFrameChoices.ONE_YEAR, OperationActivity.YearsTimeFrameChoices.FIVE_YEARS], ) enable_approach_prepositioning_operation_activity_1 = OperationActivityFactory.create( activity="Prepositioning Activity 1", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[2, 4], + time_value=[ + OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, + OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS, + ], ) enable_approach_prepositioning_operation_activity_2 = OperationActivityFactory.create( activity="Prepositioning Activity 2", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[3, 6], + time_value=[ + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + ], ) enable_approach_early_action_operation_activity_1 = OperationActivityFactory.create( activity="Early Action Activity 1", timeframe=OperationActivity.TimeFrame.DAYS, - time_value=[5, 10], + time_value=[OperationActivity.DaysTimeFrameChoices.FIVE_DAYS, OperationActivity.DaysTimeFrameChoices.TEN_DAYS], ) enable_approach_early_action_operation_activity_2 = OperationActivityFactory.create( activity="Early Action Activity 2", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[1, 3], + time_value=[ + OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + ], ) # ENABLE APPROACH with activities @@ -433,32 +454,41 @@ def test_update_simplified_eap(self): planned_operation_readiness_operation_activity_1 = OperationActivityFactory.create( activity="Readiness Activity 1", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[1, 2], + time_value=[OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS], ) planned_operation_readiness_operation_activity_2 = OperationActivityFactory.create( activity="Readiness Activity 2", timeframe=OperationActivity.TimeFrame.YEARS, - time_value=[1, 5], + time_value=[OperationActivity.YearsTimeFrameChoices.ONE_YEAR, OperationActivity.YearsTimeFrameChoices.THREE_YEARS], ) planned_operation_prepositioning_operation_activity_1 = OperationActivityFactory.create( activity="Prepositioning Activity 1", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[2, 4], + time_value=[ + OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, + OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS, + ], ) planned_operation_prepositioning_operation_activity_2 = OperationActivityFactory.create( activity="Prepositioning Activity 2", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[3, 6], + time_value=[ + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + ], ) planned_operation_early_action_operation_activity_1 = OperationActivityFactory.create( activity="Early Action Activity 1", timeframe=OperationActivity.TimeFrame.DAYS, - time_value=[5, 10], + time_value=[OperationActivity.DaysTimeFrameChoices.FIVE_DAYS, OperationActivity.DaysTimeFrameChoices.TEN_DAYS], ) planned_operation_early_action_operation_activity_2 = OperationActivityFactory.create( activity="Early Action Activity 2", timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[1, 3], + time_value=[ + OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + ], ) # PLANNED OPERATION with activities @@ -508,7 +538,7 @@ def test_update_simplified_eap(self): "id": enable_approach_readiness_operation_activity_1.id, "activity": "Updated Enable Approach Readiness Activity 1", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [2, 3], + "time_value": [OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS], } ], "prepositioning_activities": [ @@ -516,7 +546,7 @@ def test_update_simplified_eap(self): "id": enable_approach_prepositioning_operation_activity_1.id, "activity": "Updated Enable Approach Prepositioning Activity 1", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [3, 5], + "time_value": [OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS], } ], "early_action_activities": [ @@ -524,7 +554,7 @@ def test_update_simplified_eap(self): "id": enable_approach_early_action_operation_activity_1.id, "activity": "Updated Enable Approach Early Action Activity 1", "timeframe": OperationActivity.TimeFrame.DAYS, - "time_value": [7, 14], + "time_value": [OperationActivity.DaysTimeFrameChoices.TEN_DAYS], } ], }, @@ -538,21 +568,30 @@ def test_update_simplified_eap(self): { "activity": "New Enable Approach Readiness Activity", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [1, 2], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + ], } ], "prepositioning_activities": [ { "activity": "New Enable Approach Prepositioning Activity", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [2, 4], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + OperationActivity.MonthsTimeFrameChoices.NINE_MONTHS, + ], } ], "early_action_activities": [ { "activity": "New Enable Approach Early Action Activity", "timeframe": OperationActivity.TimeFrame.DAYS, - "time_value": [5, 10], + "time_value": [ + OperationActivity.DaysTimeFrameChoices.EIGHT_DAYS, + OperationActivity.DaysTimeFrameChoices.SIXTEEN_DAYS, + ], } ], }, @@ -569,7 +608,10 @@ def test_update_simplified_eap(self): "id": planned_operation_readiness_operation_activity_1.id, "activity": "Updated Planned Operation Readiness Activity 1", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [2, 4], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + ], } ], "prepositioning_activities": [ @@ -577,7 +619,10 @@ def test_update_simplified_eap(self): "id": planned_operation_prepositioning_operation_activity_1.id, "activity": "Updated Planned Operation Prepositioning Activity 1", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [3, 6], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + ], } ], "early_action_activities": [ @@ -585,7 +630,10 @@ def test_update_simplified_eap(self): "id": planned_operation_early_action_operation_activity_1.id, "activity": "Updated Planned Operation Early Action Activity 1", "timeframe": OperationActivity.TimeFrame.DAYS, - "time_value": [8, 16], + "time_value": [ + OperationActivity.DaysTimeFrameChoices.EIGHT_DAYS, + OperationActivity.DaysTimeFrameChoices.SIXTEEN_DAYS, + ], } ], }, @@ -599,21 +647,30 @@ def test_update_simplified_eap(self): { "activity": "New Planned Operation Readiness Activity", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [1, 3], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + ], } ], "prepositioning_activities": [ { "activity": "New Planned Operation Prepositioning Activity", "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [2, 5], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, + OperationActivity.MonthsTimeFrameChoices.FIVE_MONTHS, + ], } ], "early_action_activities": [ { "activity": "New Planned Operation Early Action Activity", "timeframe": OperationActivity.TimeFrame.DAYS, - "time_value": [5, 12], + "time_value": [ + OperationActivity.MonthsTimeFrameChoices.FIVE_MONTHS, + OperationActivity.MonthsTimeFrameChoices.TWELVE_MONTHS, + ], } ], }, @@ -796,10 +853,10 @@ class EAPStatusTransitionTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="XXX") + self.country = CountryFactory.create(name="country1", iso3="EAP") self.national_society = CountryFactory.create( name="national_society1", - iso3="YYY", + iso3="NSC", ) self.disaster_type = DisasterTypeFactory.create(name="disaster1") self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") @@ -1155,11 +1212,11 @@ def test_status_transition(self): self.assertIsNotNone(self.eap_registration.activated_at) -class TestSimplifiedEapPdfExport(APITestCase): +class EAPPDFExportTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="XXX") - self.national_society = CountryFactory.create(name="national_society1", iso3="YYY") + 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.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ") self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") @@ -1176,16 +1233,16 @@ def setUp(self): modified_by=self.user, ) + self.url = "/api/v2/pdf-export/" + + @mock.patch("api.serializers.generate_url.delay") + def test_create_simplified_eap_export(self, mock_generate_url): self.simplified_eap = SimplifiedEAPFactory.create( eap_registration=self.eap_registration, created_by=self.user, modified_by=self.user, national_society_contact_title="NS Title Example", ) - self.url = "/api/v2/pdf-export/" - - @mock.patch("api.serializers.generate_url.delay") - def test_create_simplified_eap_export(self, mock_generate_url): data = { "export_type": Export.ExportType.SIMPLIFIED_EAP, "export_id": self.simplified_eap.id, From d13e259eb8fcf3f595e0229771ffe70742944398 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Thu, 20 Nov 2025 13:38:50 +0545 Subject: [PATCH 19/57] feat(eap): add full eap model --- eap/admin.py | 67 ++- ...0006_sourceinformation_keyactor_fulleap.py | 149 ++++++ eap/models.py | 485 ++++++++++++++++++ 3 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 eap/migrations/0006_sourceinformation_keyactor_fulleap.py diff --git a/eap/admin.py b/eap/admin.py index b4552b024..28f6ec5b9 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from eap.models import EAPRegistration, SimplifiedEAP +from eap.models import EAPRegistration, FullEAP, KeyActor, SimplifiedEAP @admin.register(EAPRegistration) @@ -94,3 +94,68 @@ def get_queryset(self, request): "admin2", ) ) + + +@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 = ( + "cover_image", + "planned_operations", + "enable_approaches", + "planned_operations", + "hazard_files", + "exposed_element_and_vulnerability_factor_files", + "prioritized_impact_file", + "risk_analysis_relevant_file", + "forecast_selection_files", + "definition_and_justification_impact_level_files", + "identification_of_the_intervention_area_files", + "trigger_model_relevant_file", + "early_action_selection_process_file", + "evidence_base_file", + "early_action_implementation_files", + "trigger_activation_system_files", + "activation_process_relevant_files", + "meal_files", + "capacity_relevant_files", + ) + + 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", + "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/migrations/0006_sourceinformation_keyactor_fulleap.py b/eap/migrations/0006_sourceinformation_keyactor_fulleap.py new file mode 100644 index 000000000..3103b4e2e --- /dev/null +++ b/eap/migrations/0006_sourceinformation_keyactor_fulleap.py @@ -0,0 +1,149 @@ +# Generated by Django 4.2.19 on 2025-11-20 16:22 + +from django.conf import settings +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', '0226_nsdinitiativescategory_and_more'), + ('eap', '0005_eapregistration_activated_at_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='SourceInformation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Source Name')), + ('source_link', models.URLField(blank=True, max_length=255, null=True, verbose_name='Source Link')), + ], + options={ + 'verbose_name': 'Source of Information', + 'verbose_name_plural': 'Source of Information', + }, + ), + migrations.CreateModel( + name='KeyActor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, help_text='Describe this actor’s involvement.', verbose_name='Description')), + ('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')), + ('seap_timeframe', models.IntegerField(help_text='A Full EAP has a timeframe of 5 years unless early action are activated.', verbose_name='Full EAP Timeframe (Years)')), + ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), + ('partner_ns_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS name')), + ('partner_ns_email', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS email')), + ('partner_ns_title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS title')), + ('partner_ns_phone_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Partner NS phone number')), + ('ifrc_delegation_focal_point_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='IFRC delegation focal point name')), + ('ifrc_delegation_focal_point_email', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, verbose_name='IFRC head of delegation name')), + ('ifrc_head_of_delegation_email', models.CharField(blank=True, max_length=255, null=True, 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')), + ('is_worked_with_government', models.BooleanField(default=False, verbose_name='Has Worked with government or other relevant actors')), + ('worked_with_government_description', models.TextField(verbose_name='Government and actors engagement description')), + ('is_technical_working_groups_in_place', models.BooleanField(default=False, verbose_name='Are technical working groups in place')), + ('technical_working_groups_in_place_description', models.TextField(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')), + ('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')), + ('selection_area', models.TextField(help_text='Add description for the selection of the areas.', verbose_name='Selection Area Description')), + ('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')), + ('non_occurrence_usefulness', 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(verbose_name='National Society Operational, thematic and administrative capacity')), + ('strategies_and_plans', models.TextField(verbose_name='National Society Strategies and plans')), + ('advance_financial_capacity', models.TextField(verbose_name='National Society Financial capacity to advance funds')), + ('budget_description', models.TextField(verbose_name='Full EAP Budget Description')), + ('readiness_cost', models.TextField(verbose_name='Readiness Cost Description')), + ('prepositioning_cost', models.TextField(verbose_name='Prepositioning Cost Description')), + ('early_action_cost', models.TextField(verbose_name='Early Action Cost Description')), + ('budget_file', main.fields.SecureFileField(upload_to='eap/full_eap/budget_files', verbose_name='Budget File')), + ('eap_endorsement', models.TextField(help_text='Describe by whom,how and when the EAP was agreed and endorsed', verbose_name='EAP Endorsement Description')), + ('activation_process_relevant_files', models.ManyToManyField(blank=True, related_name='full_eap_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, to='api.admin2', verbose_name='admin')), + ('capacity_relevant_files', models.ManyToManyField(blank=True, related_name='full_eap_national_society_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='cover_image_full_eap', 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_files', models.ManyToManyField(blank=True, related_name='full_eap_definition_and_justification_impact_level_files', to='eap.eapfile', verbose_name='Definition and Justification Impact Level Files')), + ('eap_registration', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='full_eap', to='eap.eapregistration', verbose_name='EAP Development Registration')), + ('early_action_implementation_files', models.ManyToManyField(blank=True, related_name='full_eap_early_action_implementation_files', to='eap.eapfile', verbose_name='Early Action Implementation Files')), + ('early_action_selection_process_file', models.ManyToManyField(blank=True, related_name='full_eap_early_action_selection_process_files', to='eap.eapfile', verbose_name='Early action selection process files')), + ('enable_approaches', models.ManyToManyField(blank=True, related_name='full_eap_enable_approaches', to='eap.enableapproach', verbose_name='Enabling approaches')), + ('evidence_base_file', models.ManyToManyField(blank=True, related_name='full_eap_evidence_base_files', to='eap.eapfile', verbose_name='Evidence base files')), + ('evidence_base_source_of_information', models.ManyToManyField(blank=True, related_name='full_eap_evidence_base_source_of_information', to='eap.sourceinformation', verbose_name='Evidence base source of information')), + ('exposed_element_and_vulnerability_factor_files', models.ManyToManyField(blank=True, related_name='full_eap_vulnerability_factor_files', to='eap.eapfile', verbose_name='Exposed elements and vulnerability factors files')), + ('forecast_selection_files', models.ManyToManyField(blank=True, related_name='full_eap_forecast_selection_files', to='eap.eapfile', verbose_name='Forecast Selection Files')), + ('hazard_files', models.ManyToManyField(blank=True, related_name='full_eap_hazard_files', to='eap.eapfile', verbose_name='Hazard files')), + ('identification_of_the_intervention_area_files', models.ManyToManyField(blank=True, related_name='full_eap_identification_of_the_intervention_area_files', to='eap.eapfile', verbose_name='Intervention Area Files')), + ('key_actors', models.ManyToManyField(related_name='full_eap_key_actor', to='eap.keyactor', verbose_name='Key Actors')), + ('meal_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')), + ('planned_operations', models.ManyToManyField(blank=True, related_name='full_eap_planned_operation', to='eap.plannedoperation', verbose_name='Planned operations')), + ('prioritized_impact_file', models.ManyToManyField(blank=True, related_name='full_eap_prioritized_impact_files', to='eap.eapfile', verbose_name='Prioritized impact files')), + ('risk_analysis_relevant_file', 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='full_eap_risk_analysis_source_of_information', to='eap.sourceinformation', verbose_name='Risk analysis source of information')), + ('trigger_activation_system_files', models.ManyToManyField(blank=True, related_name='full_eap_trigger_activation_system_files', to='eap.eapfile', verbose_name='Trigger Activation System Files')), + ('trigger_model_relevant_file', 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='full_eap_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='full_eap_trigger_statement_source_of_information', to='eap.sourceinformation', verbose_name='Trigger Statement Source of Information')), + ], + options={ + 'verbose_name': 'Full EAP', + 'verbose_name_plural': 'Full EAPs', + }, + ), + ] diff --git a/eap/models.py b/eap/models.py index ffdd68807..0b18adf0d 100644 --- a/eap/models.py +++ b/eap/models.py @@ -430,6 +430,48 @@ def __str__(self): return f"Enable Approach - {self.get_approach_display()}" +class SourceInformation(models.Model): + source_name = models.CharField( + verbose_name=_("Source Name"), + null=True, + blank=True, + max_length=255, + ) + source_link = models.URLField( + verbose_name=_("Source Link"), + null=True, + blank=True, + max_length=255, + ) + + 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."), + blank=True, + ) + + class Meta: + verbose_name = _("Key Actor") + verbose_name_plural = _("Key Actor") + + class EAPType(models.IntegerChoices): """Enum representing the type of EAP.""" @@ -1013,3 +1055,446 @@ def generate_snapshot(self): # Setting Parent as locked self.is_locked = True self.save(update_fields=["is_locked"]) + + +class FullEAP(EAPBaseModel): + """Model representing a Full EAP.""" + + eap_registration = models.OneToOneField[EAPRegistration, EAPRegistration]( + EAPRegistration, + on_delete=models.CASCADE, + verbose_name=_("EAP Development Registration"), + related_name="full_eap", + ) + + cover_image = models.ForeignKey[EAPFile | None, EAPFile | None]( + EAPFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("cover image"), + related_name="cover_image_full_eap", + ) + + seap_timeframe = models.IntegerField( + verbose_name=_("Full EAP Timeframe (Years)"), + help_text=_("A Full EAP has a timeframe of 5 years unless early action are activated."), + ) + + # Contacts + # National Society + national_society_contact_name = models.CharField( + verbose_name=_("national society contact name"), max_length=255, null=True, blank=True + ) + 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, null=True, blank=True + ) + national_society_contact_phone_number = models.CharField( + verbose_name=_("national society contact phone number"), max_length=100, null=True, blank=True + ) + # Partners NS + partner_ns_name = models.CharField(verbose_name=_("Partner NS name"), max_length=255, null=True, blank=True) + partner_ns_email = models.CharField(verbose_name=_("Partner NS email"), max_length=255, null=True, blank=True) + partner_ns_title = models.CharField(verbose_name=_("Partner NS title"), max_length=255, null=True, blank=True) + partner_ns_phone_number = models.CharField(verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True) + + # Delegation + # IFRC Delegation focal point + + ifrc_delegation_focal_point_name = models.CharField( + verbose_name=_("IFRC delegation focal point name"), max_length=255, null=True, blank=True + ) + ifrc_delegation_focal_point_email = models.CharField( + verbose_name=_("IFRC delegation focal point email"), max_length=255, null=True, blank=True + ) + 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 + ifrc_head_of_delegation_name = models.CharField( + verbose_name=_("IFRC head of delegation name"), max_length=255, null=True, blank=True + ) + ifrc_head_of_delegation_email = models.CharField( + verbose_name=_("IFRC head of delegation email"), max_length=255, null=True, blank=True + ) + 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 focal point + 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 Coordinator + 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 + ) + # 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"), + ) + + is_technical_working_groups_in_place = models.BooleanField( + verbose_name=_("Are technical working groups in place"), + default=False, + ) + + technical_working_groups_in_place_description = models.TextField( + verbose_name=_("Technical working groups description"), + ) + key_actors = models.ManyToManyField( + KeyActor, + verbose_name=_("Key Actors"), + related_name="full_eap_key_actor", + ) + + # 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_files = models.ManyToManyField( + EAPFile, + verbose_name=_("Hazard files"), + related_name="full_eap_hazard_files", + 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_files = models.ManyToManyField( + EAPFile, + verbose_name=_("Exposed elements and vulnerability factors files"), + related_name="full_eap_vulnerability_factor_files", + 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_impact_file = models.ManyToManyField( + EAPFile, + verbose_name=_("Prioritized impact files"), + related_name="full_eap_prioritized_impact_files", + blank=True, + ) + + risk_analysis_relevant_file = 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="full_eap_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."), + ) + + trigger_statement_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Trigger Statement Source of Information"), + related_name="full_eap_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_selection_files = models.ManyToManyField( + EAPFile, + verbose_name=_("Forecast Selection Files"), + related_name="full_eap_forecast_selection_files", + blank=True, + ) + + definition_and_justification_impact_level = models.TextField( + verbose_name=_("Definition and Justification of Impact Level"), + ) + + definition_and_justification_impact_level_files = models.ManyToManyField( + EAPFile, + verbose_name=_("Definition and Justification Impact Level Files"), + related_name="full_eap_definition_and_justification_impact_level_files", + blank=True, + ) + + identification_of_the_intervention_area = models.TextField( + verbose_name=_("Identification of Intervention Area"), + ) + + identification_of_the_intervention_area_files = models.ManyToManyField( + EAPFile, + verbose_name=_("Intervention Area Files"), + related_name="full_eap_identification_of_the_intervention_area_files", + blank=True, + ) + + selection_area = models.TextField( + verbose_name=_("Selection Area Description"), + help_text=_("Add description for the selection of the areas."), + ) + + admin2 = models.ManyToManyField( + Admin2, + verbose_name=_("admin"), + blank=True, + ) + + trigger_model_relevant_file = 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="full_eap_trigger_model_source_of_information", + blank=True, + ) + + # SELECTION OF ACTION + early_action_selection_process = models.TextField( + verbose_name=_("Early action selection process"), + ) + + early_action_selection_process_file = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Early action selection process files"), + related_name="full_eap_early_action_selection_process_files", + ) + + evidence_base = models.TextField( + verbose_name=_("Evidence base"), + help_text="Explain how the selected actions will reduce the expected disaster impacts.", + ) + + evidence_base_file = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Evidence base files"), + related_name="full_eap_evidence_base_files", + ) + + evidence_base_source_of_information = models.ManyToManyField( + SourceInformation, + verbose_name=_("Evidence base source of information"), + related_name="full_eap_evidence_base_source_of_information", + blank=True, + ) + planned_operations = models.ManyToManyField( + PlannedOperation, + verbose_name=_("Planned operations"), + related_name="full_eap_planned_operation", + blank=True, + ) + enable_approaches = models.ManyToManyField( + EnableApproach, + verbose_name=_("Enabling approaches"), + related_name="full_eap_enable_approaches", + blank=True, + ) + + non_occurrence_usefulness = 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_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Early Action Implementation Files"), + related_name="full_eap_early_action_implementation_files", + ) + + trigger_activation_system = models.TextField( + verbose_name=_("Trigger Activation System"), + help_text=_("Describe the automatic system used to monitor the forecasts."), + ) + + trigger_activation_system_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("Trigger Activation System Files"), + related_name="full_eap_trigger_activation_system_files", + ) + + 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="full_eap_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_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"), + ) + strategies_and_plans = models.TextField( + verbose_name=_("National Society Strategies and plans"), + ) + advance_financial_capacity = models.TextField( + verbose_name=_("National Society Financial capacity to advance funds"), + ) + capacity_relevant_files = models.ManyToManyField( + EAPFile, + blank=True, + verbose_name=_("National society capacity relevant files"), + related_name="full_eap_national_society_capacity_relevant_files", + ) + + # FINANCE AND LOGISTICS + + budget_description = models.TextField(verbose_name=_("Full EAP Budget Description")) + readiness_cost = models.TextField(verbose_name=_("Readiness Cost Description")) + prepositioning_cost = models.TextField(verbose_name=_("Prepositioning Cost Description")) + early_action_cost = models.TextField(verbose_name=_("Early Action Cost Description")) + budget_file = SecureFileField(verbose_name=_("Budget File"), upload_to="eap/full_eap/budget_files") + + # 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") + ) + + # TYPING + eap_registration_id: int + id = int + + class Meta: + verbose_name = _("Full EAP") + verbose_name_plural = _("Full EAPs") + + def __str__(self): + return f"Full EAP for {self.eap_registration}" From 0706a8c8c54837e74dac1f38fbbef2025a1dbe7e Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 21 Nov 2025 17:15:45 +0545 Subject: [PATCH 20/57] feat(eap): Update changes on Full EAP - Add new endpoint for FUllEAP - Add new serializer, filter_set --- eap/admin.py | 15 +- eap/filter_set.py | 8 +- ...0006_sourceinformation_keyactor_fulleap.py | 149 --- ...ion_alter_simplifiedeap_admin2_and_more.py | 934 ++++++++++++++++++ eap/models.py | 281 ++---- eap/serializers.py | 88 ++ eap/views.py | 67 +- main/urls.py | 1 + 8 files changed, 1193 insertions(+), 350 deletions(-) delete mode 100644 eap/migrations/0006_sourceinformation_keyactor_fulleap.py create mode 100644 eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py diff --git a/eap/admin.py b/eap/admin.py index 28f6ec5b9..050d3dbbc 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -120,20 +120,21 @@ class FullEAPAdmin(admin.ModelAdmin): "planned_operations", "enable_approaches", "planned_operations", - "hazard_files", + "hazard_selection_files", + "theory_of_change_table_file", "exposed_element_and_vulnerability_factor_files", - "prioritized_impact_file", - "risk_analysis_relevant_file", + "prioritized_impact_files", + "risk_analysis_relevant_files", "forecast_selection_files", "definition_and_justification_impact_level_files", "identification_of_the_intervention_area_files", - "trigger_model_relevant_file", - "early_action_selection_process_file", - "evidence_base_file", + "trigger_model_relevant_files", + "early_action_selection_process_files", + "evidence_base_files", "early_action_implementation_files", "trigger_activation_system_files", "activation_process_relevant_files", - "meal_files", + "meal_relevant_files", "capacity_relevant_files", ) diff --git a/eap/filter_set.py b/eap/filter_set.py index 1ca9814e6..910036b02 100644 --- a/eap/filter_set.py +++ b/eap/filter_set.py @@ -1,7 +1,7 @@ import django_filters as filters from api.models import Country, DisasterType -from eap.models import EAPRegistration, EAPStatus, EAPType, SimplifiedEAP +from eap.models import EAPRegistration, EAPStatus, EAPType, FullEAP, SimplifiedEAP class BaseEAPFilterSet(filters.FilterSet): @@ -48,3 +48,9 @@ class SimplifiedEAPFilterSet(BaseEAPFilterSet): class Meta: model = SimplifiedEAP fields = ("eap_registration",) + + +class FullEAPFilterSet(BaseEAPFilterSet): + class Meta: + model = FullEAP + fields = ("eap_registration",) diff --git a/eap/migrations/0006_sourceinformation_keyactor_fulleap.py b/eap/migrations/0006_sourceinformation_keyactor_fulleap.py deleted file mode 100644 index 3103b4e2e..000000000 --- a/eap/migrations/0006_sourceinformation_keyactor_fulleap.py +++ /dev/null @@ -1,149 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-20 16:22 - -from django.conf import settings -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', '0226_nsdinitiativescategory_and_more'), - ('eap', '0005_eapregistration_activated_at_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='SourceInformation', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('source_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Source Name')), - ('source_link', models.URLField(blank=True, max_length=255, null=True, verbose_name='Source Link')), - ], - options={ - 'verbose_name': 'Source of Information', - 'verbose_name_plural': 'Source of Information', - }, - ), - migrations.CreateModel( - name='KeyActor', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('description', models.TextField(blank=True, help_text='Describe this actor’s involvement.', verbose_name='Description')), - ('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')), - ('seap_timeframe', models.IntegerField(help_text='A Full EAP has a timeframe of 5 years unless early action are activated.', verbose_name='Full EAP Timeframe (Years)')), - ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), - ('partner_ns_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS name')), - ('partner_ns_email', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS email')), - ('partner_ns_title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS title')), - ('partner_ns_phone_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Partner NS phone number')), - ('ifrc_delegation_focal_point_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='IFRC delegation focal point name')), - ('ifrc_delegation_focal_point_email', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, verbose_name='IFRC head of delegation name')), - ('ifrc_head_of_delegation_email', models.CharField(blank=True, max_length=255, null=True, 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')), - ('is_worked_with_government', models.BooleanField(default=False, verbose_name='Has Worked with government or other relevant actors')), - ('worked_with_government_description', models.TextField(verbose_name='Government and actors engagement description')), - ('is_technical_working_groups_in_place', models.BooleanField(default=False, verbose_name='Are technical working groups in place')), - ('technical_working_groups_in_place_description', models.TextField(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')), - ('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')), - ('selection_area', models.TextField(help_text='Add description for the selection of the areas.', verbose_name='Selection Area Description')), - ('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')), - ('non_occurrence_usefulness', 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(verbose_name='National Society Operational, thematic and administrative capacity')), - ('strategies_and_plans', models.TextField(verbose_name='National Society Strategies and plans')), - ('advance_financial_capacity', models.TextField(verbose_name='National Society Financial capacity to advance funds')), - ('budget_description', models.TextField(verbose_name='Full EAP Budget Description')), - ('readiness_cost', models.TextField(verbose_name='Readiness Cost Description')), - ('prepositioning_cost', models.TextField(verbose_name='Prepositioning Cost Description')), - ('early_action_cost', models.TextField(verbose_name='Early Action Cost Description')), - ('budget_file', main.fields.SecureFileField(upload_to='eap/full_eap/budget_files', verbose_name='Budget File')), - ('eap_endorsement', models.TextField(help_text='Describe by whom,how and when the EAP was agreed and endorsed', verbose_name='EAP Endorsement Description')), - ('activation_process_relevant_files', models.ManyToManyField(blank=True, related_name='full_eap_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, to='api.admin2', verbose_name='admin')), - ('capacity_relevant_files', models.ManyToManyField(blank=True, related_name='full_eap_national_society_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='cover_image_full_eap', 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_files', models.ManyToManyField(blank=True, related_name='full_eap_definition_and_justification_impact_level_files', to='eap.eapfile', verbose_name='Definition and Justification Impact Level Files')), - ('eap_registration', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='full_eap', to='eap.eapregistration', verbose_name='EAP Development Registration')), - ('early_action_implementation_files', models.ManyToManyField(blank=True, related_name='full_eap_early_action_implementation_files', to='eap.eapfile', verbose_name='Early Action Implementation Files')), - ('early_action_selection_process_file', models.ManyToManyField(blank=True, related_name='full_eap_early_action_selection_process_files', to='eap.eapfile', verbose_name='Early action selection process files')), - ('enable_approaches', models.ManyToManyField(blank=True, related_name='full_eap_enable_approaches', to='eap.enableapproach', verbose_name='Enabling approaches')), - ('evidence_base_file', models.ManyToManyField(blank=True, related_name='full_eap_evidence_base_files', to='eap.eapfile', verbose_name='Evidence base files')), - ('evidence_base_source_of_information', models.ManyToManyField(blank=True, related_name='full_eap_evidence_base_source_of_information', to='eap.sourceinformation', verbose_name='Evidence base source of information')), - ('exposed_element_and_vulnerability_factor_files', models.ManyToManyField(blank=True, related_name='full_eap_vulnerability_factor_files', to='eap.eapfile', verbose_name='Exposed elements and vulnerability factors files')), - ('forecast_selection_files', models.ManyToManyField(blank=True, related_name='full_eap_forecast_selection_files', to='eap.eapfile', verbose_name='Forecast Selection Files')), - ('hazard_files', models.ManyToManyField(blank=True, related_name='full_eap_hazard_files', to='eap.eapfile', verbose_name='Hazard files')), - ('identification_of_the_intervention_area_files', models.ManyToManyField(blank=True, related_name='full_eap_identification_of_the_intervention_area_files', to='eap.eapfile', verbose_name='Intervention Area Files')), - ('key_actors', models.ManyToManyField(related_name='full_eap_key_actor', to='eap.keyactor', verbose_name='Key Actors')), - ('meal_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')), - ('planned_operations', models.ManyToManyField(blank=True, related_name='full_eap_planned_operation', to='eap.plannedoperation', verbose_name='Planned operations')), - ('prioritized_impact_file', models.ManyToManyField(blank=True, related_name='full_eap_prioritized_impact_files', to='eap.eapfile', verbose_name='Prioritized impact files')), - ('risk_analysis_relevant_file', 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='full_eap_risk_analysis_source_of_information', to='eap.sourceinformation', verbose_name='Risk analysis source of information')), - ('trigger_activation_system_files', models.ManyToManyField(blank=True, related_name='full_eap_trigger_activation_system_files', to='eap.eapfile', verbose_name='Trigger Activation System Files')), - ('trigger_model_relevant_file', 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='full_eap_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='full_eap_trigger_statement_source_of_information', to='eap.sourceinformation', verbose_name='Trigger Statement Source of Information')), - ], - options={ - 'verbose_name': 'Full EAP', - 'verbose_name_plural': 'Full EAPs', - }, - ), - ] diff --git a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py b/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py new file mode 100644 index 000000000..7a3337d6f --- /dev/null +++ b/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py @@ -0,0 +1,934 @@ +# Generated by Django 4.2.19 on 2025-11-24 15:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import main.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0226_nsdinitiativescategory_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("eap", "0007_alter_eapfile_options_alter_eapregistration_options_and_more"), + ] + + 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"), + ), + ], + options={ + "verbose_name": "Source of Information", + "verbose_name_plural": "Source of Information", + }, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="admin2", + field=models.ManyToManyField( + blank=True, related_name="+", to="api.admin2", verbose_name="admin" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="cover_image", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="eap.eapfile", + verbose_name="cover image", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="eap_registration", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="simplified_eap", + to="eap.eapregistration", + verbose_name="EAP Development Registration", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="seap_timeframe", + field=models.IntegerField( + help_text="Timeframe of the EAP in years.", + verbose_name="Timeframe (Years) of the EAP", + ), + ), + 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", + ), + ), + ( + "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"), + ), + ( + "seap_timeframe", + models.IntegerField( + help_text="Timeframe of the EAP in years.", + verbose_name="Timeframe (Years) of the EAP", + ), + ), + ( + "national_society_contact_name", + models.CharField( + blank=True, + max_length=255, + null=True, + 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( + blank=True, + max_length=255, + null=True, + 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", + ), + ), + ( + "partner_ns_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Partner NS name", + ), + ), + ( + "partner_ns_email", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Partner NS email", + ), + ), + ( + "partner_ns_title", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Partner NS title", + ), + ), + ( + "partner_ns_phone_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Partner NS phone number", + ), + ), + ( + "ifrc_delegation_focal_point_name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point name", + ), + ), + ( + "ifrc_delegation_focal_point_email", + models.CharField( + blank=True, + max_length=255, + null=True, + 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( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation name", + ), + ), + ( + "ifrc_head_of_delegation_email", + models.CharField( + blank=True, + max_length=255, + null=True, + 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", + ), + ), + ( + "is_worked_with_government", + models.BooleanField( + default=False, + verbose_name="Has Worked with government or other relevant actors.", + ), + ), + ( + "worked_with_government_description", + models.TextField( + 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( + 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", + ), + ), + ( + "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" + ), + ), + ( + "selection_area", + models.TextField( + help_text="Add description for the selection of the areas.", + verbose_name="Areas selection rationale", + ), + ), + ( + "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"), + ), + ( + "budget_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/full_eap/budget_files", + verbose_name="Budget File", + ), + ), + ( + "readiness_cost", + models.TextField(verbose_name="Readiness Cost Description"), + ), + ( + "prepositioning_cost", + models.TextField(verbose_name="Prepositioning Cost Description"), + ), + ( + "early_action_cost", + 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", + ), + ), + ( + "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", + ), + ), + ( + "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_files", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Definition and Justification Impact Level Files", + ), + ), + ( + "eap_registration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="full_eap", + to="eap.eapregistration", + verbose_name="EAP Development Registration", + ), + ), + ( + "early_action_implementation_files", + models.ManyToManyField( + blank=True, + related_name="early_action_implementation_files", + to="eap.eapfile", + verbose_name="Early Action Implementation Files", + ), + ), + ( + "early_action_selection_process_files", + models.ManyToManyField( + blank=True, + related_name="early_action_selection_process_files", + to="eap.eapfile", + verbose_name="Early action selection process files", + ), + ), + ( + "enable_approaches", + models.ManyToManyField( + blank=True, + related_name="full_eap_enable_approaches", + to="eap.enableapproach", + verbose_name="Enabling approaches", + ), + ), + ( + "evidence_base_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_evidence_base_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_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_vulnerability_factor_files", + to="eap.eapfile", + verbose_name="Exposed elements and vulnerability factors files", + ), + ), + ( + "forecast_selection_files", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Forecast Selection Files", + ), + ), + ( + "hazard_selection_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_hazard_selection_files", + to="eap.eapfile", + verbose_name="Hazard files", + ), + ), + ( + "identification_of_the_intervention_area_files", + models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Intervention Area Files", + ), + ), + ( + "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", + ), + ), + ( + "planned_operations", + models.ManyToManyField( + blank=True, + related_name="full_eap_planned_operation", + to="eap.plannedoperation", + verbose_name="Planned operations", + ), + ), + ( + "prioritized_impact_files", + models.ManyToManyField( + blank=True, + related_name="full_eap_prioritized_impact_files", + to="eap.eapfile", + verbose_name="Prioritized impact files", + ), + ), + ( + "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_files", + models.ManyToManyField( + blank=True, + related_name="trigger_activation_system_files", + to="eap.eapfile", + verbose_name="Trigger Activation System Files", + ), + ), + ( + "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 Information", + ), + ), + ], + options={ + "verbose_name": "Full EAP", + "verbose_name_plural": "Full EAPs", + "ordering": ["-id"], + }, + ), + ] diff --git a/eap/models.py b/eap/models.py index 0b18adf0d..d8b8290c8 100644 --- a/eap/models.py +++ b/eap/models.py @@ -433,14 +433,10 @@ def __str__(self): class SourceInformation(models.Model): source_name = models.CharField( verbose_name=_("Source Name"), - null=True, - blank=True, max_length=255, ) source_link = models.URLField( verbose_name=_("Source Link"), - null=True, - blank=True, max_length=255, ) @@ -464,7 +460,6 @@ class KeyActor(models.Model): description = models.TextField( verbose_name=_("Description"), help_text=_("Describe this actor’s involvement."), - blank=True, ) class Meta: @@ -694,15 +689,8 @@ def update_eap_type(self, eap_type: EAPType, commit: bool = True): self.save(update_fields=("eap_type",)) -class SimplifiedEAP(EAPBaseModel): - """Model representing a Simplified EAP.""" - - eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( - EAPRegistration, - on_delete=models.CASCADE, - verbose_name=_("EAP Development Registration"), - related_name="simplified_eap", - ) +class CommonEAPFields(models.Model): + """Common fields for EAP models.""" cover_image = models.ForeignKey[EAPFile | None, EAPFile | None]( EAPFile, @@ -710,11 +698,19 @@ class SimplifiedEAP(EAPBaseModel): blank=True, null=True, verbose_name=_("cover image"), - related_name="cover_image_simplified_eap", + related_name="+", ) + seap_timeframe = models.IntegerField( - verbose_name=_("sEAP Timeframe (Years)"), - help_text=_("A simplified EAP has a timeframe of 2 years unless early action are activated."), + verbose_name=_("Timeframe (Years) of the EAP"), + help_text=_("Timeframe of the EAP in years."), + ) + + admin2 = models.ManyToManyField( + Admin2, + verbose_name=_("admin"), + blank=True, + related_name="+", ) # Contacts @@ -830,6 +826,20 @@ class SimplifiedEAP(EAPBaseModel): verbose_name=_("IFRC global ops coordinator phone number"), max_length=100, null=True, blank=True ) + class Meta: + abstract = True + + +class SimplifiedEAP(EAPBaseModel, CommonEAPFields): + """Model representing a Simplified EAP.""" + + eap_registration = models.OneToOneField[EAPRegistration, EAPRegistration]( + EAPRegistration, + on_delete=models.CASCADE, + verbose_name=_("EAP Development Registration"), + related_name="simplified_eap", + ) + # RISK ANALYSIS and EARLY ACTION SELECTION # # RISK ANALYSIS # @@ -885,12 +895,6 @@ class SimplifiedEAP(EAPBaseModel): blank=True, ) - admin2 = models.ManyToManyField( - Admin2, - verbose_name=_("admin2"), - blank=True, - ) - people_targeted = models.IntegerField( verbose_name=_("People Targeted."), null=True, @@ -1057,145 +1061,19 @@ def generate_snapshot(self): self.save(update_fields=["is_locked"]) -class FullEAP(EAPBaseModel): +class FullEAP(EAPBaseModel, CommonEAPFields): """Model representing a Full EAP.""" - eap_registration = models.OneToOneField[EAPRegistration, EAPRegistration]( + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( EAPRegistration, on_delete=models.CASCADE, verbose_name=_("EAP Development Registration"), related_name="full_eap", ) - cover_image = models.ForeignKey[EAPFile | None, EAPFile | None]( - EAPFile, - on_delete=models.SET_NULL, - blank=True, - null=True, - verbose_name=_("cover image"), - related_name="cover_image_full_eap", - ) - - seap_timeframe = models.IntegerField( - verbose_name=_("Full EAP Timeframe (Years)"), - help_text=_("A Full EAP has a timeframe of 5 years unless early action are activated."), - ) - - # Contacts - # National Society - national_society_contact_name = models.CharField( - verbose_name=_("national society contact name"), max_length=255, null=True, blank=True - ) - 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, null=True, blank=True - ) - national_society_contact_phone_number = models.CharField( - verbose_name=_("national society contact phone number"), max_length=100, null=True, blank=True - ) - # Partners NS - partner_ns_name = models.CharField(verbose_name=_("Partner NS name"), max_length=255, null=True, blank=True) - partner_ns_email = models.CharField(verbose_name=_("Partner NS email"), max_length=255, null=True, blank=True) - partner_ns_title = models.CharField(verbose_name=_("Partner NS title"), max_length=255, null=True, blank=True) - partner_ns_phone_number = models.CharField(verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True) - - # Delegation - # IFRC Delegation focal point - - ifrc_delegation_focal_point_name = models.CharField( - verbose_name=_("IFRC delegation focal point name"), max_length=255, null=True, blank=True - ) - ifrc_delegation_focal_point_email = models.CharField( - verbose_name=_("IFRC delegation focal point email"), max_length=255, null=True, blank=True - ) - 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 - ifrc_head_of_delegation_name = models.CharField( - verbose_name=_("IFRC head of delegation name"), max_length=255, null=True, blank=True - ) - ifrc_head_of_delegation_email = models.CharField( - verbose_name=_("IFRC head of delegation email"), max_length=255, null=True, blank=True - ) - 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 focal point - 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 Coordinator - 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 - ) # STAKEHOLDERS is_worked_with_government = models.BooleanField( - verbose_name=_("Has Worked with government or other relevant actors"), + verbose_name=_("Has Worked with government or other relevant actors."), default=False, ) @@ -1203,30 +1081,38 @@ class FullEAP(EAPBaseModel): verbose_name=_("Government and actors engagement description"), ) - is_technical_working_groups_in_place = models.BooleanField( - verbose_name=_("Are technical working groups in place"), - default=False, - ) - - technical_working_groups_in_place_description = models.TextField( - verbose_name=_("Technical working groups description"), - ) 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"), + ) + # 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_files = models.ManyToManyField( + hazard_selection_files = models.ManyToManyField( EAPFile, verbose_name=_("Hazard files"), - related_name="full_eap_hazard_files", + related_name="full_eap_hazard_selection_files", blank=True, ) @@ -1247,14 +1133,14 @@ class FullEAP(EAPBaseModel): help_text=_("Describe the impacts that have been prioritized and who is most likely to be affected."), ) - prioritized_impact_file = models.ManyToManyField( + prioritized_impact_files = models.ManyToManyField( EAPFile, verbose_name=_("Prioritized impact files"), related_name="full_eap_prioritized_impact_files", blank=True, ) - risk_analysis_relevant_file = models.ManyToManyField( + risk_analysis_relevant_files = models.ManyToManyField( EAPFile, blank=True, verbose_name=_("Risk analysis relevant files"), @@ -1264,7 +1150,7 @@ class FullEAP(EAPBaseModel): risk_analysis_source_of_information = models.ManyToManyField( SourceInformation, verbose_name=_("Risk analysis source of information"), - related_name="full_eap_risk_analysis_source_of_information", + related_name="risk_analysis_source_of_information", blank=True, ) @@ -1277,7 +1163,7 @@ class FullEAP(EAPBaseModel): trigger_statement_source_of_information = models.ManyToManyField( SourceInformation, verbose_name=_("Trigger Statement Source of Information"), - related_name="full_eap_trigger_statement_source_of_information", + related_name="trigger_statement_source_of_information", blank=True, ) @@ -1289,7 +1175,7 @@ class FullEAP(EAPBaseModel): forecast_selection_files = models.ManyToManyField( EAPFile, verbose_name=_("Forecast Selection Files"), - related_name="full_eap_forecast_selection_files", + related_name="+", blank=True, ) @@ -1300,7 +1186,7 @@ class FullEAP(EAPBaseModel): definition_and_justification_impact_level_files = models.ManyToManyField( EAPFile, verbose_name=_("Definition and Justification Impact Level Files"), - related_name="full_eap_definition_and_justification_impact_level_files", + related_name="+", blank=True, ) @@ -1311,22 +1197,16 @@ class FullEAP(EAPBaseModel): identification_of_the_intervention_area_files = models.ManyToManyField( EAPFile, verbose_name=_("Intervention Area Files"), - related_name="full_eap_identification_of_the_intervention_area_files", + related_name="+", blank=True, ) selection_area = models.TextField( - verbose_name=_("Selection Area Description"), + verbose_name=_("Areas selection rationale"), help_text=_("Add description for the selection of the areas."), ) - admin2 = models.ManyToManyField( - Admin2, - verbose_name=_("admin"), - blank=True, - ) - - trigger_model_relevant_file = models.ManyToManyField( + trigger_model_relevant_files = models.ManyToManyField( EAPFile, blank=True, verbose_name=_("Trigger Model Relevant File"), @@ -1336,7 +1216,7 @@ class FullEAP(EAPBaseModel): trigger_model_source_of_information = models.ManyToManyField( SourceInformation, verbose_name=_("Target Model Source of Information"), - related_name="full_eap_trigger_model_source_of_information", + related_name="trigger_model_source_of_information", blank=True, ) @@ -1345,11 +1225,19 @@ class FullEAP(EAPBaseModel): verbose_name=_("Early action selection process"), ) - early_action_selection_process_file = models.ManyToManyField( + early_action_selection_process_files = models.ManyToManyField( EAPFile, blank=True, verbose_name=_("Early action selection process files"), - related_name="full_eap_early_action_selection_process_files", + related_name="early_action_selection_process_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( @@ -1357,7 +1245,7 @@ class FullEAP(EAPBaseModel): help_text="Explain how the selected actions will reduce the expected disaster impacts.", ) - evidence_base_file = models.ManyToManyField( + evidence_base_files = models.ManyToManyField( EAPFile, blank=True, verbose_name=_("Evidence base files"), @@ -1367,9 +1255,11 @@ class FullEAP(EAPBaseModel): evidence_base_source_of_information = models.ManyToManyField( SourceInformation, verbose_name=_("Evidence base source of information"), - related_name="full_eap_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"), @@ -1383,7 +1273,7 @@ class FullEAP(EAPBaseModel): blank=True, ) - non_occurrence_usefulness = models.TextField( + 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."), ) @@ -1404,7 +1294,7 @@ class FullEAP(EAPBaseModel): EAPFile, blank=True, verbose_name=_("Early Action Implementation Files"), - related_name="full_eap_early_action_implementation_files", + related_name="early_action_implementation_files", ) trigger_activation_system = models.TextField( @@ -1416,7 +1306,7 @@ class FullEAP(EAPBaseModel): EAPFile, blank=True, verbose_name=_("Trigger Activation System Files"), - related_name="full_eap_trigger_activation_system_files", + related_name="trigger_activation_system_files", ) selection_of_target_population = models.TextField( @@ -1435,7 +1325,7 @@ class FullEAP(EAPBaseModel): EAPFile, blank=True, verbose_name=_("Activation Relevant Files"), - related_name="full_eap_activation_process_relevant_files", + related_name="activation_process_relevant_files", ) activation_process_source_of_information = models.ManyToManyField( @@ -1450,7 +1340,7 @@ class FullEAP(EAPBaseModel): meal = models.TextField( verbose_name=_("MEAL Plan Description"), ) - meal_files = models.ManyToManyField( + meal_relevant_files = models.ManyToManyField( EAPFile, blank=True, verbose_name=_("Meal files"), @@ -1460,32 +1350,42 @@ class FullEAP(EAPBaseModel): # 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="full_eap_national_society_capacity_relevant_files", + related_name="ns_capacity_relevant_files", ) # FINANCE AND LOGISTICS budget_description = models.TextField(verbose_name=_("Full EAP Budget Description")) + budget_file = SecureFileField( + verbose_name=_("Budget File"), + upload_to="eap/full_eap/budget_files", + null=True, + blank=True, + ) + readiness_cost = models.TextField(verbose_name=_("Readiness Cost Description")) prepositioning_cost = models.TextField(verbose_name=_("Prepositioning Cost Description")) early_action_cost = models.TextField(verbose_name=_("Early Action Cost Description")) - budget_file = SecureFileField(verbose_name=_("Budget File"), upload_to="eap/full_eap/budget_files") # 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") + verbose_name=_("EAP Endorsement Description"), + help_text=("Describe by whom,how and when the EAP was agreed and endorsed."), ) # TYPING @@ -1495,6 +1395,7 @@ class FullEAP(EAPBaseModel): class Meta: verbose_name = _("Full EAP") verbose_name_plural = _("Full EAPs") + ordering = ["-id"] def __str__(self): return f"Full EAP for {self.eap_registration}" diff --git a/eap/serializers.py b/eap/serializers.py index 16cbb643e..c9cb5c852 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -17,9 +17,12 @@ EAPRegistration, EAPType, EnableApproach, + FullEAP, + KeyActor, OperationActivity, PlannedOperation, SimplifiedEAP, + SourceInformation, ) from eap.utils import ( has_country_permission, @@ -293,6 +296,27 @@ class Meta: ) +class SourceInformationSerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + + class Meta: + model = SourceInformation + fields = "__all__" + + +class KeyActorSerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + national_society_details = MiniCountrySerializer(source="national_society", read_only=True) + + class Meta: + model = KeyActor + fields = "__all__" + + class SimplifiedEAPSerializer( NestedUpdateMixin, NestedCreateMixin, @@ -359,6 +383,70 @@ def create(self, validated_data: dict[str, typing.Any]): return instance +class FullEAPSerializer( + NestedUpdateMixin, + NestedCreateMixin, + BaseEAPSerializer, +): + + MAX_NUMBER_OF_IMAGES = 5 + + planned_operations = PlannedOperationSerializer(many=True, required=False) + enable_approaches = EnableApproachSerializer(many=True, required=False) + # admins + admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) + key_actors = KeyActorSerializer(many=True, required=True) + + # SOURCE OF INFOMATIONS + risk_analysis_source_of_information = SourceInformationSerializer(many=True) + trigger_statement_source_of_information = SourceInformationSerializer(many=True) + trigger_model_source_of_information = SourceInformationSerializer(many=True) + evidence_base_source_of_information = SourceInformationSerializer(many=True) + activation_process_source_of_information = SourceInformationSerializer(many=True) + + # FILES + cover_image_details = EAPFileSerializer(source="cover_image", read_only=True) + hazard_selection_files_details = EAPFileSerializer(source="hazard_selection_files", many=True, read_only=True) + exposed_element_and_vulnerability_factor_files_details = EAPFileSerializer( + source="exposed_element_and_vulnerability_factor_files", many=True, read_only=True + ) + prioritized_impact_files_details = EAPFileSerializer(source="prioritized_impact_files", many=True, read_only=True) + risk_analysis_relevant_files_details = EAPFileSerializer(source="risk_analysis_relevant_files", many=True, read_only=True) + forecast_selection_files_details = EAPFileSerializer(source="forecast_selection_files", many=True, read_only=True) + definition_and_justification_impact_level_files_details = EAPFileSerializer( + source="definition_and_justification_impact_level_files", many=True, read_only=True + ) + identification_of_the_intervention_area_files_details = EAPFileSerializer( + source="identification_of_the_intervention_area_files", many=True, read_only=True + ) + trigger_model_relevant_files_details = EAPFileSerializer(source="trigger_model_relevant_files", many=True, read_only=True) + early_action_selection_process_files_details = EAPFileSerializer( + source="early_action_selection_process_files", many=True, read_only=True + ) + theory_of_change_table_file_details = EAPFileSerializer(source="theory_of_change_table_file", read_only=True) + evidence_base_files_details = EAPFileSerializer(source="evidence_base_files", many=True, read_only=True) + early_action_implementation_files_details = EAPFileSerializer( + source="early_action_implementation_files", many=True, read_only=True + ) + trigger_activation_system_files_details = EAPFileSerializer( + source="trigger_activation_system_files", many=True, read_only=True + ) + activation_process_relevant_files_details = EAPFileSerializer( + source="activation_process_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) + + class Meta: + model = FullEAP + fields = "__all__" + read_only_fields = ( + "created_by", + "modified_by", + ) + + +# STATUS TRANSITION SERIALIZER VALID_NS_EAP_STATUS_TRANSITIONS = set( [ (EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.UNDER_REVIEW), diff --git a/eap/views.py b/eap/views.py index 7fe1eeb18..2a354c6e4 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,12 +1,24 @@ # Create your views here. from django.db.models import Case, F, IntegerField, Value, When -from django.db.models.query import QuerySet +from django.db.models.query import Prefetch, QuerySet from drf_spectacular.utils import extend_schema from rest_framework import mixins, permissions, response, status, viewsets from rest_framework.decorators import action -from eap.filter_set import EAPRegistrationFilterSet, SimplifiedEAPFilterSet -from eap.models import EAPFile, EAPRegistration, EAPStatus, EAPType, SimplifiedEAP +from eap.filter_set import ( + EAPRegistrationFilterSet, + FullEAPFilterSet, + SimplifiedEAPFilterSet, +) +from eap.models import ( + EAPFile, + EAPRegistration, + EAPStatus, + EAPType, + FullEAP, + KeyActor, + SimplifiedEAP, +) from eap.permissions import ( EAPBasePermission, EAPRegistrationPermissions, @@ -18,6 +30,7 @@ EAPRegistrationSerializer, EAPStatusSerializer, EAPValidatedBudgetFileSerializer, + FullEAPSerializer, MiniEAPSerializer, SimplifiedEAPSerializer, ) @@ -169,6 +182,54 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: ) +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", + ) + .prefetch_related( + "admin2", + # 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_files", + "theory_of_change_table_file", + "exposed_element_and_vulnerability_factor_files", + "prioritized_impact_files", + "risk_analysis_relevant_files", + "forecast_selection_files", + "definition_and_justification_impact_level_files", + "identification_of_the_intervention_area_files", + "trigger_model_relevant_files", + "early_action_selection_process_files", + "evidence_base_files", + "early_action_implementation_files", + "trigger_activation_system_files", + "activation_process_relevant_files", + "meal_relevant_files", + "capacity_relevant_files", + Prefetch( + "key_actors", + queryset=KeyActor.objects.select_related("national_society"), + ), + ) + ) + + class EAPFileViewSet( viewsets.GenericViewSet, mixins.CreateModelMixin, diff --git a/main/urls.py b/main/urls.py index 7cd4ddef5..a2dcf7bb2 100644 --- a/main/urls.py +++ b/main/urls.py @@ -197,6 +197,7 @@ 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") admin.site.site_header = "IFRC Go administration" From 002f43bf385b0605710bc4d961bd63d6f5635ce6 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 25 Nov 2025 17:21:41 +0545 Subject: [PATCH 21/57] feat(eap): update schema on updating eap file instance - update image field names on simplifiedeap --- eap/admin.py | 6 +-- ...mplifiedeap_hazard_impact_file_and_more.py | 54 +++++++++++++++++++ eap/models.py | 18 +++---- eap/serializers.py | 34 +++++++++--- eap/views.py | 7 ++- 5 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py diff --git a/eap/admin.py b/eap/admin.py index 6771eca07..b4552b024 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -64,9 +64,9 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): ) readonly_fields = ( "cover_image", - "hazard_impact_file", - "risk_selected_protocols_file", - "selected_early_actions_file", + "hazard_impact_images", + "risk_selected_protocols_images", + "selected_early_actions_images", "planned_operations", "enable_approaches", "parent", diff --git a/eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py b/eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py new file mode 100644 index 000000000..d5ca984b5 --- /dev/null +++ b/eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.19 on 2025-11-25 10:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0007_alter_eapfile_options_alter_eapregistration_options_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="simplifiedeap", + name="hazard_impact_file", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="risk_selected_protocols_file", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="selected_early_actions_file", + ), + migrations.AddField( + model_name="simplifiedeap", + name="hazard_impact_images", + field=models.ManyToManyField( + blank=True, + related_name="simplified_eap_hazard_impact_images", + to="eap.eapfile", + verbose_name="Hazard Impact Images", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="risk_selected_protocols_images", + field=models.ManyToManyField( + blank=True, + related_name="simplified_eap_risk_selected_protocols_images", + to="eap.eapfile", + verbose_name="Risk Selected Protocols Images", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="selected_early_actions_images", + field=models.ManyToManyField( + blank=True, + related_name="simplified_eap_selected_early_actions_images", + to="eap.eapfile", + verbose_name="Selected Early Actions Images", + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index b273ab785..ffdd68807 100644 --- a/eap/models.py +++ b/eap/models.py @@ -796,10 +796,10 @@ class SimplifiedEAP(EAPBaseModel): null=True, blank=True, ) - hazard_impact_file = models.ManyToManyField( + hazard_impact_images = models.ManyToManyField( EAPFile, - verbose_name=_("Hazard Impact Files"), - related_name="simplified_eap_hazard_impact_files", + verbose_name=_("Hazard Impact Images"), + related_name="simplified_eap_hazard_impact_images", blank=True, ) @@ -809,10 +809,10 @@ class SimplifiedEAP(EAPBaseModel): blank=True, ) - risk_selected_protocols_file = models.ManyToManyField( + risk_selected_protocols_images = models.ManyToManyField( EAPFile, - verbose_name=_("Risk Selected Protocols Files"), - related_name="simplified_eap_risk_selected_protocols_files", + verbose_name=_("Risk Selected Protocols Images"), + related_name="simplified_eap_risk_selected_protocols_images", blank=True, ) @@ -822,10 +822,10 @@ class SimplifiedEAP(EAPBaseModel): null=True, blank=True, ) - selected_early_actions_file = models.ManyToManyField( + selected_early_actions_images = models.ManyToManyField( EAPFile, - verbose_name=_("Selected Early Actions Files"), - related_name="simplified_eap_selected_early_actions_files", + verbose_name=_("Selected Early Actions Images"), + related_name="simplified_eap_selected_early_actions_images", blank=True, ) diff --git a/eap/serializers.py b/eap/serializers.py index 775bda63e..16cbb643e 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -193,6 +193,24 @@ def validate_file(self, 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_file(self, file): + validate_file_type(file) + return file + + ALLOWED_MAP_TIMEFRAMES_VALUE = { OperationActivity.TimeFrame.YEARS: list(OperationActivity.YearsTimeFrameChoices.values), OperationActivity.TimeFrame.MONTHS: list(OperationActivity.MonthsTimeFrameChoices.values), @@ -286,35 +304,35 @@ class SimplifiedEAPSerializer( enable_approaches = EnableApproachSerializer(many=True, required=False) # FILES - cover_image_details = EAPFileSerializer(source="cover_image", read_only=True) - hazard_impact_file_details = EAPFileSerializer(source="hazard_impact_file", many=True, read_only=True) - selected_early_actions_file_details = EAPFileSerializer(source="selected_early_actions_file", many=True, read_only=True) - risk_selected_protocols_file_details = EAPFileSerializer(source="risk_selected_protocols_file", many=True, read_only=True) + cover_image_file = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) + hazard_impact_images_details = EAPFileSerializer(source="hazard_impact_images", many=True, read_only=True) + selected_early_actions_file_details = EAPFileSerializer(source="selected_early_actions_images", many=True, read_only=True) + risk_selected_protocols_file_details = EAPFileSerializer(source="risk_selected_protocols_images", many=True, read_only=True) # Admin2 admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) class Meta: model = SimplifiedEAP - fields = "__all__" read_only_fields = [ "version", "is_locked", ] + exclude = ("cover_image",) - def validate_hazard_impact_file(self, images): + def validate_hazard_impact_images(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") validate_file_type(images) return images - def validate_risk_selected_protocols_file(self, images): + def validate_risk_selected_protocols_images(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") validate_file_type(images) return images - def validate_selected_early_actions_file(self, images): + def validate_selected_early_actions_images(self, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") validate_file_type(images) diff --git a/eap/views.py b/eap/views.py index 077a86f40..7fe1eeb18 100644 --- a/eap/views.py +++ b/eap/views.py @@ -160,10 +160,9 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: .prefetch_related( "eap_registration__partners", "admin2", - "hazard_impact_file", - "selected_early_actions_file", - "risk_selected_protocols_file", - "selected_early_actions_file", + "hazard_impact_images", + "risk_selected_protocols_images", + "selected_early_actions_images", "planned_operations", "enable_approaches", ) From 1a9ba244bff62b9ea0ee59c9c8582733111643d1 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 24 Nov 2025 21:18:18 +0545 Subject: [PATCH 22/57] chore(eap): Update filters on eap and update migration file --- eap/filter_set.py | 74 +++++++++++++++---- ...ion_alter_simplifiedeap_admin2_and_more.py | 12 +-- eap/models.py | 2 +- eap/views.py | 24 +++++- 4 files changed, 81 insertions(+), 31 deletions(-) diff --git a/eap/filter_set.py b/eap/filter_set.py index 910036b02..5e3ba16ac 100644 --- a/eap/filter_set.py +++ b/eap/filter_set.py @@ -4,9 +4,34 @@ from eap.models import EAPRegistration, EAPStatus, EAPType, FullEAP, SimplifiedEAP -class BaseEAPFilterSet(filters.FilterSet): - 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 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", @@ -16,7 +41,10 @@ class BaseEAPFilterSet(filters.FilterSet): field_name="national_society", queryset=Country.objects.all(), ) - region = filters.NumberFilter(field_name="country__region_id", label="Region") + region = filters.NumberFilter( + field_name="country__region_id", + label="Region", + ) partners = filters.ModelMultipleChoiceFilter( field_name="partners", queryset=Country.objects.all(), @@ -28,23 +56,39 @@ class BaseEAPFilterSet(filters.FilterSet): queryset=DisasterType.objects.all(), ) + class Meta: + model = EAPRegistration + fields = () -class EAPRegistrationFilterSet(BaseEAPFilterSet): - eap_type = filters.ChoiceFilter( - choices=EAPType.choices, - label="EAP Type", + +class BaseEAPFilterSet(BaseFilterSet): + eap_registration = filters.ModelMultipleChoiceFilter( + field_name="eap_registration", + queryset=EAPRegistration.objects.all(), ) - status = filters.ChoiceFilter( - choices=EAPStatus.choices, - label="EAP Status", + + seap_timeframe = filters.NumberFilter( + field_name="seap_timeframe", + label="SEAP Timeframe (in Years)", ) - class Meta: - model = EAPRegistration - fields = () + 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): +class SimplifiedEAPFilterSet(BaseEAPFilterSet, BaseFilterSet): class Meta: model = SimplifiedEAP fields = ("eap_registration",) diff --git a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py b/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py index 7a3337d6f..42e72e336 100644 --- a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py +++ b/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-11-24 15:11 +# Generated by Django 4.2.19 on 2025-11-24 15:26 from django.conf import settings from django.db import migrations, models @@ -59,16 +59,6 @@ class Migration(migrations.Migration): verbose_name="cover image", ), ), - migrations.AlterField( - model_name="simplifiedeap", - name="eap_registration", - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="simplified_eap", - to="eap.eapregistration", - verbose_name="EAP Development Registration", - ), - ), migrations.AlterField( model_name="simplifiedeap", name="seap_timeframe", diff --git a/eap/models.py b/eap/models.py index d8b8290c8..92c62c55d 100644 --- a/eap/models.py +++ b/eap/models.py @@ -833,7 +833,7 @@ class Meta: class SimplifiedEAP(EAPBaseModel, CommonEAPFields): """Model representing a Simplified EAP.""" - eap_registration = models.OneToOneField[EAPRegistration, EAPRegistration]( + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( EAPRegistration, on_delete=models.CASCADE, verbose_name=_("EAP Development Registration"), diff --git a/eap/views.py b/eap/views.py index 2a354c6e4..0e278ac8b 100644 --- a/eap/views.py +++ b/eap/views.py @@ -88,7 +88,11 @@ class EAPRegistrationViewSet(EAPModelViewSet): queryset = EAPRegistration.objects.all() lookup_field = "id" serializer_class = EAPRegistrationSerializer - permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission, EAPRegistrationPermissions] + permission_classes = [ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPRegistrationPermissions, + ] filterset_class = EAPRegistrationFilterSet def get_queryset(self) -> QuerySet[EAPRegistration]: @@ -135,7 +139,11 @@ def update_status( url_path="upload-validated-budget-file", methods=["post"], serializer_class=EAPValidatedBudgetFileSerializer, - permission_classes=[permissions.IsAuthenticated, DenyGuestUserPermission, EAPValidatedBudgetPermission], + permission_classes=[ + permissions.IsAuthenticated, + DenyGuestUserPermission, + EAPValidatedBudgetPermission, + ], ) def upload_validated_budget_file( self, @@ -157,7 +165,11 @@ class SimplifiedEAPViewSet(EAPModelViewSet): lookup_field = "id" serializer_class = SimplifiedEAPSerializer filterset_class = SimplifiedEAPFilterSet - permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission, EAPBasePermission] + permission_classes = [ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPBasePermission, + ] def get_queryset(self) -> QuerySet[SimplifiedEAP]: return ( @@ -187,7 +199,11 @@ class FullEAPViewSet(EAPModelViewSet): lookup_field = "id" serializer_class = FullEAPSerializer filterset_class = FullEAPFilterSet - permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission, EAPBasePermission] + permission_classes = [ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPBasePermission, + ] def get_queryset(self) -> QuerySet[FullEAP]: return ( From 39ce6659da8a9b290a77b8edf5ac4f79cd68c89d Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 26 Nov 2025 14:34:23 +0545 Subject: [PATCH 23/57] fix(eap): Update test cases for simplified eap generate pdf --- eap/test_views.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/eap/test_views.py b/eap/test_views.py index 22d9b6277..fad8aaa5d 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -5,6 +5,7 @@ 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 @@ -1236,7 +1237,7 @@ def setUp(self): self.url = "/api/v2/pdf-export/" @mock.patch("api.serializers.generate_url.delay") - def test_create_simplified_eap_export(self, mock_generate_url): + def test_simplified_eap_export(self, mock_generate_url): self.simplified_eap = SimplifiedEAPFactory.create( eap_registration=self.eap_registration, created_by=self.user, @@ -1254,20 +1255,18 @@ def test_create_simplified_eap_export(self, mock_generate_url): with self.capture_on_commit_callbacks(execute=True): response = self.client.post(self.url, data, format="json") self.assert_201(response) - export = Export.objects.first() - self.assertIsNotNone(export) + self.assertIsNotNone(response.data["id"], response.data) - expected_url = ( - f"{settings.GO_WEB_INTERNAL_URL}/" f"{Export.ExportType.SIMPLIFIED_EAP}/" f"{self.simplified_eap.id}/export/" - ) - self.assertEqual(export.url, expected_url) + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/{Export.ExportType.SIMPLIFIED_EAP}/{self.simplified_eap.id}/export/" + 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( - export.url, - export.id, + expected_url, + response.data["id"], self.user.id, title, + django_get_language(), ) From 41f6728c0b60f6641020f07a154338b1225c02c0 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 24 Nov 2025 23:11:02 +0545 Subject: [PATCH 24/57] feat(full_eap): Add snapshot feature and update on active EAPs - Add new CommonEAPFields - Add new CommonEAPFieldsSerializer - Add full eap snapshot utility function - Update validation checks on images --- ...ion_alter_simplifiedeap_admin2_and_more.py | 63 +++++++++- eap/models.py | 119 +++++++++++++----- eap/serializers.py | 95 +++++++++----- eap/utils.py | 6 +- eap/views.py | 35 +++--- 5 files changed, 230 insertions(+), 88 deletions(-) diff --git a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py b/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py index 42e72e336..d4d6d55bb 100644 --- a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py +++ b/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-11-24 15:26 +# Generated by Django 4.2.19 on 2025-11-24 16:00 from django.conf import settings from django.db import migrations, models @@ -8,8 +8,8 @@ class Migration(migrations.Migration): dependencies = [ - ("api", "0226_nsdinitiativescategory_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0226_nsdinitiativescategory_and_more"), ("eap", "0007_alter_eapfile_options_alter_eapregistration_options_and_more"), ] @@ -453,6 +453,31 @@ class Migration(migrations.Migration): verbose_name="IFRC global ops coordinator phone number", ), ), + ( + "updated_checklist_file", + main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="Updated 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)"), + ), ( "is_worked_with_government", models.BooleanField( @@ -632,15 +657,15 @@ class Migration(migrations.Migration): ), ), ( - "readiness_cost", + "readiness_cost_description", models.TextField(verbose_name="Readiness Cost Description"), ), ( - "prepositioning_cost", + "prepositioning_cost_description", models.TextField(verbose_name="Prepositioning Cost Description"), ), ( - "early_action_cost", + "early_action_cost_description", models.TextField(verbose_name="Early Action Cost Description"), ), ( @@ -650,6 +675,22 @@ class Migration(migrations.Migration): 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( @@ -831,6 +872,18 @@ class Migration(migrations.Migration): 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", + ), + ), ( "planned_operations", models.ManyToManyField( diff --git a/eap/models.py b/eap/models.py index 92c62c55d..303151353 100644 --- a/eap/models.py +++ b/eap/models.py @@ -305,6 +305,7 @@ class HoursTimeFrameChoices(models.IntegerChoices): activity = models.CharField(max_length=255, verbose_name=_("Activity")) timeframe = models.IntegerField(choices=TimeFrame.choices, verbose_name=_("Timeframe")) + # TODO(susilnem): Use enums for time_value? time_value = ArrayField( base_field=models.IntegerField(), verbose_name=_("Activity time span"), @@ -644,10 +645,12 @@ class EAPRegistration(EAPBaseModel): ) # TYPING + id: int national_society_id = int country_id = int disaster_type_id = int - id = int + simplified_eap: models.Manager["SimplifiedEAP"] + full_eap: models.Manager["FullEAP"] class Meta: verbose_name = _("Development Registration EAP") @@ -661,10 +664,7 @@ def __str__(self): @property def has_eap_application(self) -> bool: """Check if the EAP Registration has an associated EAP application.""" - # TODO(susilnem): Add FULL EAP check, when model is created. - if hasattr(self, "simplified_eap") and self.simplified_eap.exists(): - return True - return False + return self.simplified_eap.exists() or self.full_eap.exists() @property def get_status_enum(self) -> EAPStatus: @@ -826,6 +826,28 @@ class CommonEAPFields(models.Model): verbose_name=_("IFRC global ops coordinator phone number"), max_length=100, null=True, blank=True ) + # Review Checklist + updated_checklist_file = SecureFileField( + verbose_name=_("Updated Checklist File"), + upload_to="eap/files/", + null=True, + blank=True, + ) + + # BUDGET # + 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)"), + ) + class Meta: abstract = True @@ -970,20 +992,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - # BUDGET # - 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)"), - ) - # BUDGET DETAILS # budget_file = SecureFileField( verbose_name=_("Budget File"), @@ -992,14 +1000,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - # Review Checklist - updated_checklist_file = SecureFileField( - verbose_name=_("Updated Checklist File"), - upload_to="eap/files/", - null=True, - blank=True, - ) - # NOTE: Snapshot fields version = models.IntegerField( verbose_name=_("Version"), @@ -1022,9 +1022,9 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): ) # TYPING + id: int eap_registration_id: int parent_id: int - id = int class Meta: verbose_name = _("Simplified EAP") @@ -1041,8 +1041,9 @@ def generate_snapshot(self): from eap.utils import copy_model_instance + # TODO(susilnem): Verify the fields to exclude? with transaction.atomic(): - copy_model_instance( + instance = copy_model_instance( self, overrides={ "parent_id": self.id, @@ -1059,6 +1060,7 @@ def generate_snapshot(self): # Setting Parent as locked self.is_locked = True self.save(update_fields=["is_locked"]) + return instance class FullEAP(EAPBaseModel, CommonEAPFields): @@ -1377,9 +1379,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - readiness_cost = models.TextField(verbose_name=_("Readiness Cost Description")) - prepositioning_cost = models.TextField(verbose_name=_("Prepositioning Cost Description")) - early_action_cost = models.TextField(verbose_name=_("Early Action Cost 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 @@ -1388,9 +1390,31 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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 - id = int + parent_id: int | None class Meta: verbose_name = _("Full EAP") @@ -1398,4 +1422,31 @@ class Meta: ordering = ["-id"] def __str__(self): - return f"Full EAP for {self.eap_registration}" + 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, + "updated_checklist_file": None, + }, + exclude_clone_m2m_fields=[ + "admin2", + ], + ) + + # Setting Parent as locked + self.is_locked = True + self.save(update_fields=["is_locked"]) + return instance diff --git a/eap/serializers.py b/eap/serializers.py index c9cb5c852..729698c43 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -70,6 +70,9 @@ def update(self, instance, validated_data: dict[str, typing.Any]): return super().update(instance, validated_data) +# NOTE: Mini Serializers used for basic listing purpose + + class MiniSimplifiedEAPSerializer( serializers.ModelSerializer, ): @@ -90,6 +93,26 @@ class Meta: ] +class MiniFullEAPSerializer( + serializers.ModelSerializer, +): + class Meta: + model = FullEAP + fields = [ + "id", + "eap_registration", + "total_budget", + "readiness_budget", + "pre_positioning_budget", + "early_action_budget", + "seap_timeframe", + "budget_file", + "version", + "is_locked", + "updated_checklist_file", + ] + + 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) @@ -129,6 +152,7 @@ class EAPRegistrationSerializer( # EAPs simplified_eap_details = MiniSimplifiedEAPSerializer(source="simplified_eap", many=True, read_only=True) + full_eap_details = MiniFullEAPSerializer(source="full_eap", many=True, read_only=True) # Status status_display = serializers.CharField(source="get_status_display", read_only=True) @@ -146,7 +170,7 @@ class Meta: ] def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: - # Cannot update once EAP application is being created. + # 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) @@ -317,11 +341,7 @@ class Meta: fields = "__all__" -class SimplifiedEAPSerializer( - NestedUpdateMixin, - NestedCreateMixin, - BaseEAPSerializer, -): +class CommonEAPFieldsSerializer(serializers.ModelSerializer): MAX_NUMBER_OF_IMAGES = 5 planned_operations = PlannedOperationSerializer(many=True, required=False) @@ -329,13 +349,35 @@ class SimplifiedEAPSerializer( # FILES cover_image_file = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) + admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) + + def get_fields(self): + fields = super().get_fields() + 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=False) + fields["enable_approaches"] = EnableApproachSerializer(many=True, required=False) + return fields + + def validate_images_field(self, images): + if images and len(images) > self.MAX_NUMBER_OF_IMAGES: + raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed.") + validate_file_type(images) + return images + + +class SimplifiedEAPSerializer( + NestedUpdateMixin, + NestedCreateMixin, + BaseEAPSerializer, + CommonEAPFieldsSerializer, +): + + # FILES hazard_impact_images_details = EAPFileSerializer(source="hazard_impact_images", many=True, read_only=True) selected_early_actions_file_details = EAPFileSerializer(source="selected_early_actions_images", many=True, read_only=True) risk_selected_protocols_file_details = EAPFileSerializer(source="risk_selected_protocols_images", many=True, read_only=True) - # Admin2 - admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) - class Meta: model = SimplifiedEAP read_only_fields = [ @@ -345,21 +387,15 @@ class Meta: exclude = ("cover_image",) def validate_hazard_impact_images(self, images): - if images and len(images) > self.MAX_NUMBER_OF_IMAGES: - raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") - validate_file_type(images) + self.validate_images_field(images) return images def validate_risk_selected_protocols_images(self, images): - if images and len(images) > self.MAX_NUMBER_OF_IMAGES: - raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") - validate_file_type(images) + self.validate_images_field(images) return images def validate_selected_early_actions_images(self, images): - if images and len(images) > self.MAX_NUMBER_OF_IMAGES: - raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed to upload.") - validate_file_type(images) + self.validate_images_field(images) return images def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: @@ -387,14 +423,10 @@ class FullEAPSerializer( NestedUpdateMixin, NestedCreateMixin, BaseEAPSerializer, + CommonEAPFieldsSerializer, ): - MAX_NUMBER_OF_IMAGES = 5 - - planned_operations = PlannedOperationSerializer(many=True, required=False) - enable_approaches = EnableApproachSerializer(many=True, required=False) # admins - admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) key_actors = KeyActorSerializer(many=True, required=True) # SOURCE OF INFOMATIONS @@ -405,7 +437,6 @@ class FullEAPSerializer( activation_process_source_of_information = SourceInformationSerializer(many=True) # FILES - cover_image_details = EAPFileSerializer(source="cover_image", read_only=True) hazard_selection_files_details = EAPFileSerializer(source="hazard_selection_files", many=True, read_only=True) exposed_element_and_vulnerability_factor_files_details = EAPFileSerializer( source="exposed_element_and_vulnerability_factor_files", many=True, read_only=True @@ -439,11 +470,11 @@ class FullEAPSerializer( class Meta: model = FullEAP - fields = "__all__" read_only_fields = ( "created_by", "modified_by", ) + exclude = ("cover_image",) # STATUS TRANSITION SERIALIZER @@ -514,13 +545,15 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) - # NOTE: Add checks for FULL EAP - simplified_eap_instance: SimplifiedEAP | None = ( - SimplifiedEAP.objects.filter(eap_registration=self.instance).order_by("-version").first() - ) + # latest Simplified EAP + eap_instance = SimplifiedEAP.objects.filter(eap_registration=self.instance).order_by("-version").first() + + # If no Simplified EAP, check for Full EAP + if not eap_instance: + eap_instance = FullEAP.objects.filter(eap_registration=self.instance).order_by("-version").first() - if simplified_eap_instance: - simplified_eap_instance.generate_snapshot() + assert eap_instance is not None, "EAP instance does not exist." + eap_instance.generate_snapshot() elif (current_status, new_status) == ( EAPRegistration.Status.UNDER_REVIEW, diff --git a/eap/utils.py b/eap/utils.py index b294aad82..8f281722a 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError -from eap.models import SimplifiedEAP +from eap.models import FullEAP, SimplifiedEAP def has_country_permission(user: User, country_id: int) -> bool: @@ -50,10 +50,10 @@ def validate_file_extention(filename: str, allowed_extensions: list[str]): def copy_model_instance( - instance: SimplifiedEAP, + instance: SimplifiedEAP | FullEAP, overrides: dict[str, typing.Any] | None = None, exclude_clone_m2m_fields: list[str] | None = None, -) -> SimplifiedEAP: +) -> SimplifiedEAP | FullEAP: """ Creates a copy of a Django model instance, including its many-to-many relationships. diff --git a/eap/views.py b/eap/views.py index 0e278ac8b..b79105b39 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,5 +1,6 @@ # Create your views here. -from django.db.models import Case, F, IntegerField, Value, When +from django.db.models import Case, IntegerField, Subquery, When +from django.db.models.expressions import OuterRef from django.db.models.query import Prefetch, QuerySet from drf_spectacular.utils import extend_schema from rest_framework import mixins, permissions, response, status, viewsets @@ -55,29 +56,33 @@ class ActiveEAPViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): filterset_class = EAPRegistrationFilterSet def get_queryset(self) -> QuerySet[EAPRegistration]: + latest_simplified_eap = ( + SimplifiedEAP.objects.filter(eap_registration=OuterRef("id"), is_locked=False) + .order_by("-version") + .values("total_budget")[:1] + ) + + latest_full_eap = ( + FullEAP.objects.filter(eap_registration=OuterRef("id"), is_locked=False) + .order_by("-version") + .values("total_budget")[:1] + ) + return ( super() .get_queryset() .filter(status__in=[EAPStatus.APPROVED, EAPStatus.ACTIVATED]) - .select_related( - "disaster_type", - "country", - ) + .select_related("disaster_type", "country") .annotate( requirement_cost=Case( - # TODO(susilnem): Verify the requirements(CHF) field map When( eap_type=EAPType.SIMPLIFIED_EAP, - then=SimplifiedEAP.objects.filter(eap_registration=F("id")) - .order_by("version") - .values("total_budget")[:1], + then=Subquery(latest_simplified_eap), + ), + When( + eap_type=EAPType.FULL_EAP, + then=Subquery(latest_full_eap), ), - # TODO(susilnem): Add check for FullEAP - # When( - # eap_type=EAPType.FULL_EAP, - # then=FullEAP.objects.filter(eap_registration=F("id")).order_by("version").values("total_budget")[:1], - # ) - default=Value(0), output_field=IntegerField(), ) ) From 583f6b9734b12111afdbd00c0b151a0027690c58 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 25 Nov 2025 15:15:53 +0545 Subject: [PATCH 25/57] feat(full-eap): Add test cases for full-eap --- eap/factories.py | 12 + ...on_alter_simplifiedeap_admin2_and_more.py} | 6 +- eap/models.py | 6 +- eap/serializers.py | 31 ++- eap/test_views.py | 206 +++++++++++++++++- 5 files changed, 245 insertions(+), 16 deletions(-) rename eap/migrations/{0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py => 0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py} (99%) diff --git a/eap/factories.py b/eap/factories.py index c1d2f3c3d..8599bdcef 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -6,6 +6,7 @@ EAPStatus, EAPType, EnableApproach, + FullEAP, OperationActivity, PlannedOperation, SimplifiedEAP, @@ -138,3 +139,14 @@ def early_action_activities(self, create, extracted, **kwargs): if extracted: for activity in extracted: self.early_action_activities.add(activity) + + +class FullEAPFactory(factory.django.DjangoModelFactory): + class Meta: + model = FullEAP + + seap_timeframe = fuzzy.FuzzyInteger(5) + 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) diff --git a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py b/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py similarity index 99% rename from eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py rename to eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py index d4d6d55bb..be039a29f 100644 --- a/eap/migrations/0008_sourceinformation_alter_simplifiedeap_admin2_and_more.py +++ b/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-11-24 16:00 +# Generated by Django 4.2.19 on 2025-11-26 09:04 from django.conf import settings from django.db import migrations, models @@ -8,9 +8,9 @@ class Migration(migrations.Migration): dependencies = [ + ("api", "0227_alter_export_export_type"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("api", "0226_nsdinitiativescategory_and_more"), - ("eap", "0007_alter_eapfile_options_alter_eapregistration_options_and_more"), + ("eap", "0008_remove_simplifiedeap_hazard_impact_file_and_more"), ] operations = [ diff --git a/eap/models.py b/eap/models.py index 303151353..9f7cc305b 100644 --- a/eap/models.py +++ b/eap/models.py @@ -646,9 +646,9 @@ class EAPRegistration(EAPBaseModel): # TYPING id: int - national_society_id = int - country_id = int - disaster_type_id = int + national_society_id: int + country_id: int + disaster_type_id: int simplified_eap: models.Manager["SimplifiedEAP"] full_eap: models.Manager["FullEAP"] diff --git a/eap/serializers.py b/eap/serializers.py index 729698c43..33f0c3a4b 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -375,8 +375,8 @@ class SimplifiedEAPSerializer( # FILES hazard_impact_images_details = EAPFileSerializer(source="hazard_impact_images", many=True, read_only=True) - selected_early_actions_file_details = EAPFileSerializer(source="selected_early_actions_images", many=True, read_only=True) - risk_selected_protocols_file_details = EAPFileSerializer(source="risk_selected_protocols_images", many=True, read_only=True) + selected_early_actions_images_details = EAPFileSerializer(source="selected_early_actions_images", many=True, read_only=True) + risk_selected_protocols_images_details = EAPFileSerializer(source="risk_selected_protocols_images", many=True, read_only=True) class Meta: model = SimplifiedEAP @@ -406,7 +406,7 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: # NOTE: Cannot update locked Simplified EAP if self.instance and self.instance.is_locked: - raise serializers.ValidationError("Cannot update locked Simplified EAP.") + 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: @@ -430,11 +430,11 @@ class FullEAPSerializer( key_actors = KeyActorSerializer(many=True, required=True) # SOURCE OF INFOMATIONS - risk_analysis_source_of_information = SourceInformationSerializer(many=True) - trigger_statement_source_of_information = SourceInformationSerializer(many=True) - trigger_model_source_of_information = SourceInformationSerializer(many=True) - evidence_base_source_of_information = SourceInformationSerializer(many=True) - activation_process_source_of_information = SourceInformationSerializer(many=True) + risk_analysis_source_of_information = SourceInformationSerializer(many=True, required=False) + trigger_statement_source_of_information = SourceInformationSerializer(many=True, required=False) + trigger_model_source_of_information = SourceInformationSerializer(many=True, required=False) + evidence_base_source_of_information = SourceInformationSerializer(many=True, required=False) + activation_process_source_of_information = SourceInformationSerializer(many=True, required=False) # FILES hazard_selection_files_details = EAPFileSerializer(source="hazard_selection_files", many=True, read_only=True) @@ -476,6 +476,21 @@ class Meta: ) exclude = ("cover_image",) + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: + eap_registration: EAPRegistration = data["eap_registration"] + + if not self.instance and eap_registration.has_eap_application: + raise serializers.ValidationError("Full EAP for this EAP registration already exists.") + + # 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.") + return data + # STATUS TRANSITION SERIALIZER VALID_NS_EAP_STATUS_TRANSITIONS = set( diff --git a/eap/test_views.py b/eap/test_views.py index fad8aaa5d..6fa27d1d4 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -14,6 +14,7 @@ from eap.factories import ( EAPRegistrationFactory, EnableApproachFactory, + FullEAPFactory, OperationActivityFactory, PlannedOperationFactory, SimplifiedEAPFactory, @@ -296,7 +297,7 @@ def test_create_simplified_eap(self): "next_step_towards_full_eap": "Plan to expand.", "planned_operations": [ { - "sector": 101, + "sector": PlannedOperation.Sector.SETTLEMENT_AND_HOUSING, "ap_code": 111, "people_targeted": 10000, "budget_per_sector": 100000, @@ -332,7 +333,7 @@ def test_create_simplified_eap(self): "enable_approaches": [ { "ap_code": 11, - "approach": 10, + "approach": EnableApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, "indicator_target": 10000, "early_action_activities": [ @@ -1270,3 +1271,204 @@ def test_simplified_eap_export(self, mock_generate_url): 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, + ) + + 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, + ) + + data = { + "eap_registration": eap_registration.id, + "total_budget": 10000, + "seap_timeframe": 5, + "readiness_budget": 3000, + "pre_positioning_budget": 4000, + "early_action_budget": 3000, + "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", + "selection_area": "Selection of the area", + "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, + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [ + OperationActivity.YearsTimeFrameChoices.ONE_YEAR, + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [ + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + ], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [OperationActivity.YearsTimeFrameChoices.FIVE_YEARS], + } + ], + } + ], + "enable_approaches": [ + { + "ap_code": 11, + "approach": EnableApproach.Approach.SECRETARIAT_SERVICES, + "budget_per_approach": 10000, + "indicator_target": 10000, + "early_action_activities": [ + { + "activity": "early action activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [ + OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + ], + } + ], + "prepositioning_activities": [ + { + "activity": "prepositioning activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [OperationActivity.YearsTimeFrameChoices.THREE_YEARS], + } + ], + "readiness_activities": [ + { + "activity": "readiness activity", + "timeframe": OperationActivity.TimeFrame.YEARS, + "time_value": [ + OperationActivity.YearsTimeFrameChoices.FIVE_YEARS, + OperationActivity.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.", + ) + + # 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) From 1821d24b349b58d4f5d308b106e04934e79a972f Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 26 Nov 2025 15:53:24 +0545 Subject: [PATCH 26/57] feat(eap): Add full eap export pdf - Add full eap export test cases --- .../0228_alter_export_export_type.py | 29 ++++++++++++++++ api/models.py | 1 + api/serializers.py | 5 ++- eap/test_views.py | 33 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 api/migrations/0228_alter_export_export_type.py diff --git a/api/migrations/0228_alter_export_export_type.py b/api/migrations/0228_alter_export_export_type.py new file mode 100644 index 000000000..df5dffc7f --- /dev/null +++ b/api/migrations/0228_alter_export_export_type.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.26 on 2025-11-26 10:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0227_alter_export_export_type"), + ] + + 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-eap", "Simplified EAP"), + ("full-eap", "Full EAP"), + ], + max_length=255, + verbose_name="Export Type", + ), + ), + ] diff --git a/api/models.py b/api/models.py index 23541c9f2..398e141e5 100644 --- a/api/models.py +++ b/api/models.py @@ -2565,6 +2565,7 @@ class ExportType(models.TextChoices): OLD_FINAL_REPORT = "old-dref-final-reports", _("Old DREF Final Report") PER = "per", _("Per") SIMPLIFIED_EAP = "simplified-eap", _("Simplified EAP") + FULL_EAP = "full-eap", _("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/serializers.py b/api/serializers.py index 72c6d8ce4..baba5d0ae 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -15,7 +15,7 @@ from api.utils import CountryValidator, RegionValidator from deployments.models import EmergencyProject, Personnel, PersonnelDeployment from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate -from eap.models import SimplifiedEAP +from eap.models import FullEAP, SimplifiedEAP from lang.models import String from lang.serializers import ModelSerializer from local_units.models import DelegationOffice @@ -2575,6 +2575,9 @@ def create(self, validated_data): title = ( f"{simplified_eap.eap_registration.national_society.name}-{simplified_eap.eap_registration.disaster_type.name}" ) + elif export_type == Export.ExportType.FULL_EAP: + full_eap = FullEAP.objects.filter(id=export_id).first() + title = f"{full_eap.eap_registration.national_society.name}-{full_eap.eap_registration.disaster_type.name}" else: title = "Export" user = self.context["request"].user diff --git a/eap/test_views.py b/eap/test_views.py index 6fa27d1d4..d9c94fd61 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1272,6 +1272,39 @@ def test_simplified_eap_export(self, mock_generate_url): django_get_language(), ) + @mock.patch("api.serializers.generate_url.delay") + def test_full_eap_export(self, mock_generate_url): + self.full_eap = FullEAPFactory.create( + eap_registration=self.eap_registration, + created_by=self.user, + modified_by=self.user, + ) + data = { + "export_type": Export.ExportType.FULL_EAP, + "export_id": self.full_eap.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}/{Export.ExportType.FULL_EAP}/{self.full_eap.id}/export/" + 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( + expected_url, + response.data["id"], + self.user.id, + title, + django_get_language(), + ) + class EAPFullTestCase(APITestCase): def setUp(self): From d4a9c654a062f109d2945f3a54dde212229041e3 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 26 Nov 2025 21:32:14 +0545 Subject: [PATCH 27/57] feat(eap): Update full eap fields and add new fields - Update test cases, admin, serializers - Make budget file required in both eaps --- eap/admin.py | 27 +++--- eap/factories.py | 27 ++++++ ...ion_alter_simplifiedeap_admin2_and_more.py | 93 +++++++++++-------- eap/models.py | 89 +++++++++--------- eap/serializers.py | 47 ++++++---- eap/test_views.py | 78 +++++++++++++--- eap/views.py | 22 +++-- 7 files changed, 250 insertions(+), 133 deletions(-) diff --git a/eap/admin.py b/eap/admin.py index 050d3dbbc..939a94136 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1,6 +1,11 @@ from django.contrib import admin -from eap.models import EAPRegistration, FullEAP, KeyActor, SimplifiedEAP +from eap.models import EAPFile, EAPRegistration, FullEAP, KeyActor, SimplifiedEAP + + +@admin.register(EAPFile) +class EAPFileAdmin(admin.ModelAdmin): + search_fields = ("caption",) @admin.register(EAPRegistration) @@ -120,19 +125,19 @@ class FullEAPAdmin(admin.ModelAdmin): "planned_operations", "enable_approaches", "planned_operations", - "hazard_selection_files", + "hazard_selection_images", "theory_of_change_table_file", - "exposed_element_and_vulnerability_factor_files", - "prioritized_impact_files", + "exposed_element_and_vulnerability_factor_images", + "prioritized_impact_images", "risk_analysis_relevant_files", - "forecast_selection_files", - "definition_and_justification_impact_level_files", - "identification_of_the_intervention_area_files", + "forecast_selection_images", + "definition_and_justification_impact_level_images", + "identification_of_the_intervention_area_images", "trigger_model_relevant_files", - "early_action_selection_process_files", - "evidence_base_files", - "early_action_implementation_files", - "trigger_activation_system_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", diff --git a/eap/factories.py b/eap/factories.py index 8599bdcef..c431a8518 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -2,6 +2,7 @@ from factory import fuzzy from eap.models import ( + EAPFile, EAPRegistration, EAPStatus, EAPType, @@ -13,6 +14,30 @@ ) +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 EAPRegistrationFactory(factory.django.DjangoModelFactory): class Meta: model = EAPRegistration @@ -39,6 +64,7 @@ class Meta: 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) @factory.post_generation def enable_approaches(self, create, extracted, **kwargs): @@ -150,3 +176,4 @@ class Meta: 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) diff --git a/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py b/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py index be039a29f..ffa1622ff 100644 --- a/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py +++ b/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-11-26 09:04 +# Generated by Django 4.2.26 on 2025-11-26 15:19 from django.conf import settings from django.db import migrations, models @@ -8,8 +8,8 @@ class Migration(migrations.Migration): dependencies = [ - ("api", "0227_alter_export_export_type"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0228_alter_export_export_type"), ("eap", "0008_remove_simplifiedeap_hazard_impact_file_and_more"), ] @@ -47,6 +47,16 @@ class Migration(migrations.Migration): blank=True, related_name="+", to="api.admin2", verbose_name="admin" ), ), + migrations.AlterField( + model_name="simplifiedeap", + name="budget_file", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="eap.eapfile", + verbose_name="Budget File", + ), + ), migrations.AlterField( model_name="simplifiedeap", name="cover_image", @@ -59,6 +69,11 @@ class Migration(migrations.Migration): verbose_name="cover image", ), ), + migrations.AlterField( + model_name="simplifiedeap", + name="people_targeted", + field=models.IntegerField(verbose_name="People Targeted."), + ), migrations.AlterField( model_name="simplifiedeap", name="seap_timeframe", @@ -129,6 +144,10 @@ class Migration(migrations.Migration): verbose_name="Timeframe (Years) of the EAP", ), ), + ( + "people_targeted", + models.IntegerField(verbose_name="People Targeted."), + ), ( "national_society_contact_name", models.CharField( @@ -647,15 +666,6 @@ class Migration(migrations.Migration): "budget_description", models.TextField(verbose_name="Full EAP Budget Description"), ), - ( - "budget_file", - main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/full_eap/budget_files", - verbose_name="Budget File", - ), - ), ( "readiness_cost_description", models.TextField(verbose_name="Readiness Cost Description"), @@ -718,6 +728,15 @@ class Migration(migrations.Migration): 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( @@ -748,12 +767,12 @@ class Migration(migrations.Migration): ), ), ( - "definition_and_justification_impact_level_files", + "definition_and_justification_impact_level_images", models.ManyToManyField( blank=True, related_name="+", to="eap.eapfile", - verbose_name="Definition and Justification Impact Level Files", + verbose_name="Definition and Justification Impact Level Images", ), ), ( @@ -766,21 +785,21 @@ class Migration(migrations.Migration): ), ), ( - "early_action_implementation_files", + "early_action_implementation_images", models.ManyToManyField( blank=True, - related_name="early_action_implementation_files", + related_name="early_action_implementation_images", to="eap.eapfile", - verbose_name="Early Action Implementation Files", + verbose_name="Early Action Implementation Images", ), ), ( - "early_action_selection_process_files", + "early_action_selection_process_images", models.ManyToManyField( blank=True, - related_name="early_action_selection_process_files", + related_name="early_action_selection_process_images", to="eap.eapfile", - verbose_name="Early action selection process files", + verbose_name="Early action selection process images", ), ), ( @@ -793,10 +812,10 @@ class Migration(migrations.Migration): ), ), ( - "evidence_base_files", + "evidence_base_relevant_files", models.ManyToManyField( blank=True, - related_name="full_eap_evidence_base_files", + related_name="full_eap_evidence_base_relavent_files", to="eap.eapfile", verbose_name="Evidence base files", ), @@ -811,39 +830,39 @@ class Migration(migrations.Migration): ), ), ( - "exposed_element_and_vulnerability_factor_files", + "exposed_element_and_vulnerability_factor_images", models.ManyToManyField( blank=True, - related_name="full_eap_vulnerability_factor_files", + related_name="full_eap_vulnerability_factor_images", to="eap.eapfile", - verbose_name="Exposed elements and vulnerability factors files", + verbose_name="Exposed elements and vulnerability factors images", ), ), ( - "forecast_selection_files", + "forecast_selection_images", models.ManyToManyField( blank=True, related_name="+", to="eap.eapfile", - verbose_name="Forecast Selection Files", + verbose_name="Forecast Selection Images", ), ), ( - "hazard_selection_files", + "hazard_selection_images", models.ManyToManyField( blank=True, - related_name="full_eap_hazard_selection_files", + related_name="full_eap_hazard_selection_images", to="eap.eapfile", - verbose_name="Hazard files", + verbose_name="Hazard images", ), ), ( - "identification_of_the_intervention_area_files", + "identification_of_the_intervention_area_images", models.ManyToManyField( blank=True, related_name="+", to="eap.eapfile", - verbose_name="Intervention Area Files", + verbose_name="Intervention Area Images", ), ), ( @@ -894,12 +913,12 @@ class Migration(migrations.Migration): ), ), ( - "prioritized_impact_files", + "prioritized_impact_images", models.ManyToManyField( blank=True, - related_name="full_eap_prioritized_impact_files", + related_name="full_eap_prioritized_impact_images", to="eap.eapfile", - verbose_name="Prioritized impact files", + verbose_name="Prioritized impact images", ), ), ( @@ -932,12 +951,12 @@ class Migration(migrations.Migration): ), ), ( - "trigger_activation_system_files", + "trigger_activation_system_images", models.ManyToManyField( blank=True, - related_name="trigger_activation_system_files", + related_name="trigger_activation_system_images", to="eap.eapfile", - verbose_name="Trigger Activation System Files", + verbose_name="Trigger Activation System Images", ), ), ( diff --git a/eap/models.py b/eap/models.py index 9f7cc305b..c1e2ef3b4 100644 --- a/eap/models.py +++ b/eap/models.py @@ -713,6 +713,10 @@ class CommonEAPFields(models.Model): related_name="+", ) + people_targeted = models.IntegerField( + verbose_name=_("People Targeted."), + ) + # Contacts # National Society national_society_contact_name = models.CharField( @@ -835,6 +839,14 @@ class CommonEAPFields(models.Model): ) # 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)"), ) @@ -848,6 +860,9 @@ class CommonEAPFields(models.Model): verbose_name=_("Early Actions Budget (CHF)"), ) + # TYPING + budget_file_id: int + class Meta: abstract = True @@ -917,11 +932,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - people_targeted = models.IntegerField( - verbose_name=_("People Targeted."), - null=True, - blank=True, - ) assisted_through_operation = models.TextField( verbose_name=_("Assisted through the operation"), null=True, @@ -992,14 +1002,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - # BUDGET DETAILS # - budget_file = SecureFileField( - verbose_name=_("Budget File"), - upload_to="eap/simplified_eap/budget_files/", - null=True, - blank=True, - ) - # NOTE: Snapshot fields version = models.IntegerField( verbose_name=_("Version"), @@ -1111,10 +1113,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_("Provide a brief rationale for selecting this hazard for the FbF system."), ) - hazard_selection_files = models.ManyToManyField( + hazard_selection_images = models.ManyToManyField( EAPFile, - verbose_name=_("Hazard files"), - related_name="full_eap_hazard_selection_files", + verbose_name=_("Hazard images"), + related_name="full_eap_hazard_selection_images", blank=True, ) @@ -1123,10 +1125,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_("Explain which people are most likely to experience the impacts of this hazard."), ) - exposed_element_and_vulnerability_factor_files = models.ManyToManyField( + exposed_element_and_vulnerability_factor_images = models.ManyToManyField( EAPFile, - verbose_name=_("Exposed elements and vulnerability factors files"), - related_name="full_eap_vulnerability_factor_files", + verbose_name=_("Exposed elements and vulnerability factors images"), + related_name="full_eap_vulnerability_factor_images", blank=True, ) @@ -1135,10 +1137,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_("Describe the impacts that have been prioritized and who is most likely to be affected."), ) - prioritized_impact_files = models.ManyToManyField( + prioritized_impact_images = models.ManyToManyField( EAPFile, - verbose_name=_("Prioritized impact files"), - related_name="full_eap_prioritized_impact_files", + verbose_name=_("Prioritized impact images"), + related_name="full_eap_prioritized_impact_images", blank=True, ) @@ -1174,9 +1176,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_("Explain which forecast's and observations will be used and why they are chosen"), ) - forecast_selection_files = models.ManyToManyField( + forecast_selection_images = models.ManyToManyField( EAPFile, - verbose_name=_("Forecast Selection Files"), + verbose_name=_("Forecast Selection Images"), related_name="+", blank=True, ) @@ -1185,9 +1187,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): verbose_name=_("Definition and Justification of Impact Level"), ) - definition_and_justification_impact_level_files = models.ManyToManyField( + definition_and_justification_impact_level_images = models.ManyToManyField( EAPFile, - verbose_name=_("Definition and Justification Impact Level Files"), + verbose_name=_("Definition and Justification Impact Level Images"), related_name="+", blank=True, ) @@ -1196,9 +1198,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): verbose_name=_("Identification of Intervention Area"), ) - identification_of_the_intervention_area_files = models.ManyToManyField( + identification_of_the_intervention_area_images = models.ManyToManyField( EAPFile, - verbose_name=_("Intervention Area Files"), + verbose_name=_("Intervention Area Images"), related_name="+", blank=True, ) @@ -1223,16 +1225,18 @@ class FullEAP(EAPBaseModel, CommonEAPFields): ) # SELECTION OF ACTION + early_action_selection_process = models.TextField( verbose_name=_("Early action selection process"), ) - early_action_selection_process_files = models.ManyToManyField( + early_action_selection_process_images = models.ManyToManyField( EAPFile, blank=True, - verbose_name=_("Early action selection process files"), - related_name="early_action_selection_process_files", + 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, @@ -1247,11 +1251,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text="Explain how the selected actions will reduce the expected disaster impacts.", ) - evidence_base_files = models.ManyToManyField( + evidence_base_relevant_files = models.ManyToManyField( EAPFile, blank=True, verbose_name=_("Evidence base files"), - related_name="full_eap_evidence_base_files", + related_name="full_eap_evidence_base_relavent_files", ) evidence_base_source_of_information = models.ManyToManyField( @@ -1292,11 +1296,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_("Describe the process for implementing early actions."), ) - early_action_implementation_files = models.ManyToManyField( + early_action_implementation_images = models.ManyToManyField( EAPFile, blank=True, - verbose_name=_("Early Action Implementation Files"), - related_name="early_action_implementation_files", + verbose_name=_("Early Action Implementation Images"), + related_name="early_action_implementation_images", ) trigger_activation_system = models.TextField( @@ -1304,11 +1308,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_("Describe the automatic system used to monitor the forecasts."), ) - trigger_activation_system_files = models.ManyToManyField( + trigger_activation_system_images = models.ManyToManyField( EAPFile, blank=True, - verbose_name=_("Trigger Activation System Files"), - related_name="trigger_activation_system_files", + verbose_name=_("Trigger Activation System Images"), + related_name="trigger_activation_system_images", ) selection_of_target_population = models.TextField( @@ -1372,13 +1376,6 @@ class FullEAP(EAPBaseModel, CommonEAPFields): # FINANCE AND LOGISTICS budget_description = models.TextField(verbose_name=_("Full EAP Budget Description")) - budget_file = SecureFileField( - verbose_name=_("Budget File"), - upload_to="eap/full_eap/budget_files", - null=True, - blank=True, - ) - 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")) diff --git a/eap/serializers.py b/eap/serializers.py index 33f0c3a4b..b2a9f2332 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -350,6 +350,7 @@ class CommonEAPFieldsSerializer(serializers.ModelSerializer): # FILES cover_image_file = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) + budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) def get_fields(self): fields = super().get_fields() @@ -357,6 +358,7 @@ def get_fields(self): fields["cover_image_file"] = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) fields["planned_operations"] = PlannedOperationSerializer(many=True, required=False) fields["enable_approaches"] = EnableApproachSerializer(many=True, required=False) + fields["budget_file_details"] = EAPFileSerializer(source="budget_file", read_only=True) return fields def validate_images_field(self, images): @@ -437,33 +439,33 @@ class FullEAPSerializer( activation_process_source_of_information = SourceInformationSerializer(many=True, required=False) # FILES - hazard_selection_files_details = EAPFileSerializer(source="hazard_selection_files", many=True, read_only=True) + hazard_selection_images_details = EAPFileSerializer(source="hazard_selection_images", many=True, read_only=True) exposed_element_and_vulnerability_factor_files_details = EAPFileSerializer( source="exposed_element_and_vulnerability_factor_files", many=True, read_only=True ) - prioritized_impact_files_details = EAPFileSerializer(source="prioritized_impact_files", many=True, read_only=True) + prioritized_impact_images_details = EAPFileSerializer(source="prioritized_impact_images", many=True, read_only=True) risk_analysis_relevant_files_details = EAPFileSerializer(source="risk_analysis_relevant_files", many=True, read_only=True) - forecast_selection_files_details = EAPFileSerializer(source="forecast_selection_files", many=True, read_only=True) - definition_and_justification_impact_level_files_details = EAPFileSerializer( - source="definition_and_justification_impact_level_files", many=True, read_only=True + forecast_selection_images_details = EAPFileSerializer(source="forecast_selection_images", many=True, read_only=True) + definition_and_justification_impact_level_images_details = EAPFileSerializer( + source="definition_and_justification_impact_level_images", many=True, read_only=True ) - identification_of_the_intervention_area_files_details = EAPFileSerializer( - source="identification_of_the_intervention_area_files", many=True, read_only=True + identification_of_the_intervention_area_images_details = EAPFileSerializer( + source="identification_of_the_intervention_area_images", many=True, read_only=True ) trigger_model_relevant_files_details = EAPFileSerializer(source="trigger_model_relevant_files", many=True, read_only=True) - early_action_selection_process_files_details = EAPFileSerializer( - source="early_action_selection_process_files", many=True, read_only=True + early_action_selection_process_images_details = EAPFileSerializer( + source="early_action_selection_process_images", many=True, read_only=True ) theory_of_change_table_file_details = EAPFileSerializer(source="theory_of_change_table_file", read_only=True) - evidence_base_files_details = EAPFileSerializer(source="evidence_base_files", many=True, read_only=True) - early_action_implementation_files_details = EAPFileSerializer( - source="early_action_implementation_files", many=True, read_only=True + evidence_base_relevant_files_details = EAPFileSerializer(source="evidence_base_relevant_files", many=True, read_only=True) + early_action_implementation_images_details = EAPFileSerializer( + source="early_action_implementation_images", many=True, read_only=True ) - trigger_activation_system_files_details = EAPFileSerializer( - source="trigger_activation_system_files", many=True, read_only=True + trigger_activation_system_images_details = EAPFileSerializer( + source="trigger_activation_system_images", many=True, read_only=True ) - activation_process_relevant_files_details = EAPFileSerializer( - source="activation_process_relevant_files", many=True, read_only=True + activation_process_relevant_images_details = EAPFileSerializer( + source="activation_process_relevant_images", 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) @@ -476,6 +478,19 @@ class Meta: ) exclude = ("cover_image",) + # TODO(susilnem): Add validation for multiple image fields similar to SimplifiedEAP + def validate_hazard_selection_images(self, images): + self.validate_images_field(images) + return images + + def validate_exposed_element_and_vulnerability_factor_files(self, images): + self.validate_images_field(images) + return images + + def validate_prioritized_impact_images(self, images): + self.validate_images_field(images) + return images + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: eap_registration: EAPRegistration = data["eap_registration"] diff --git a/eap/test_views.py b/eap/test_views.py index d9c94fd61..659327a15 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -12,6 +12,7 @@ from api.models import Export from deployments.factories.user import UserFactory from eap.factories import ( + EAPFileFactory, EAPRegistrationFactory, EnableApproachFactory, FullEAPFactory, @@ -81,14 +82,15 @@ def test_upload_invalid_files(self): class EAPRegistrationTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="EAP") + 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") - self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + 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") @@ -215,6 +217,10 @@ def test_update_eap_registration(self): 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 = { @@ -231,14 +237,15 @@ def test_update_eap_registration(self): class EAPSimplifiedTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="EAP") + 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") - self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + 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") @@ -268,6 +275,10 @@ def test_list_simplified_eap(self): 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/" @@ -287,13 +298,19 @@ def test_create_simplified_eap(self): 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 = { "eap_registration": eap_registration.id, + "budget_file": budget_file.id, "total_budget": 10000, "seap_timeframe": 3, "readiness_budget": 3000, "pre_positioning_budget": 4000, "early_action_budget": 3000, + "people_targeted": 5000, "next_step_towards_full_eap": "Plan to expand.", "planned_operations": [ { @@ -517,6 +534,10 @@ def test_update_simplified_eap(self): 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, + ), enable_approaches=[enable_approach.id], planned_operations=[planned_operation.id], ) @@ -855,14 +876,15 @@ class EAPStatusTransitionTestCase(APITestCase): def setUp(self): super().setUp() - self.country = CountryFactory.create(name="country1", iso3="EAP") + 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") - self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + 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, @@ -918,6 +940,10 @@ def test_status_transition(self): 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, + ), ) # SUCCESS: As Simplified EAP exists @@ -1217,11 +1243,19 @@ def test_status_transition(self): class EAPPDFExportTestCase(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.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") - self.partner2 = CountryFactory.create(name="partner2", iso3="AAA") + self.partner1 = CountryFactory.create(name="partner1", iso3="ZZZ", iso="ZZ") + self.partner2 = CountryFactory.create(name="partner2", iso3="AAA", iso="AA") self.user = UserFactory.create() @@ -1244,6 +1278,10 @@ def test_simplified_eap_export(self, mock_generate_url): 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, + ), ) data = { "export_type": Export.ExportType.SIMPLIFIED_EAP, @@ -1278,6 +1316,10 @@ def test_full_eap_export(self, mock_generate_url): eap_registration=self.eap_registration, created_by=self.user, modified_by=self.user, + budget_file=EAPFileFactory._create_file( + created_by=self.user, + modified_by=self.user, + ), ) data = { "export_type": Export.ExportType.FULL_EAP, @@ -1344,6 +1386,10 @@ def test_list_full_eap(self): 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/" @@ -1365,13 +1411,19 @@ def test_create_full_eap(self): modified_by=self.country_admin, ) + budget_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) data = { "eap_registration": eap_registration.id, + "budget_file": budget_file_instance.id, "total_budget": 10000, "seap_timeframe": 5, "readiness_budget": 3000, "pre_positioning_budget": 4000, "early_action_budget": 3000, + "people_targeted": 5000, "key_actors": [ { "national_society": self.national_society.id, diff --git a/eap/views.py b/eap/views.py index b79105b39..3194f7aab 100644 --- a/eap/views.py +++ b/eap/views.py @@ -184,6 +184,7 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: "created_by", "modified_by", "cover_image", + "budget_file", "eap_registration__country", "eap_registration__disaster_type", ) @@ -217,6 +218,7 @@ def get_queryset(self) -> QuerySet[FullEAP]: .select_related( "created_by", "modified_by", + "budget_file", ) .prefetch_related( "admin2", @@ -227,19 +229,19 @@ def get_queryset(self) -> QuerySet[FullEAP]: "evidence_base_source_of_information", "activation_process_source_of_information", # Files - "hazard_selection_files", + "hazard_selection_images", "theory_of_change_table_file", - "exposed_element_and_vulnerability_factor_files", - "prioritized_impact_files", + "exposed_element_and_vulnerability_factor_images", + "prioritized_impact_images", "risk_analysis_relevant_files", - "forecast_selection_files", - "definition_and_justification_impact_level_files", - "identification_of_the_intervention_area_files", + "forecast_selection_images", + "definition_and_justification_impact_level_images", + "identification_of_the_intervention_area_images", "trigger_model_relevant_files", - "early_action_selection_process_files", - "evidence_base_files", - "early_action_implementation_files", - "trigger_activation_system_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", From 2fb2bfcd13f79a4e3d18b06826b900a058a04bd8 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 27 Nov 2025 17:00:26 +0545 Subject: [PATCH 28/57] feat(eap): add test cases for full eap, snapshot, active-eap - Add new fields for the seap timeframe and operations --- api/tasks.py | 2 + eap/enums.py | 10 +- eap/factories.py | 14 +- ....py => 0009_sourceinformation_and_more.py} | 31 +- eap/models.py | 194 +++--- eap/permissions.py | 16 +- eap/serializers.py | 159 ++++- eap/test_views.py | 574 ++++++++++++++---- eap/tests.py | 1 - eap/utils.py | 73 ++- 10 files changed, 811 insertions(+), 263 deletions(-) rename eap/migrations/{0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py => 0009_sourceinformation_and_more.py} (97%) diff --git a/api/tasks.py b/api/tasks.py index dded4d66d..c551d7507 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -120,6 +120,8 @@ def generate_url(url, export_id, user, title, language): file_name = f'PER {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' elif export.export_type == Export.ExportType.SIMPLIFIED_EAP: file_name = f'SIMPLIFIED EAP {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' + elif export.export_type == Export.ExportType.FULL_EAP: + file_name = f'FULL EAP {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( diff --git a/eap/enums.py b/eap/enums.py index f8eb9c5c5..ee9f78248 100644 --- a/eap/enums.py +++ b/eap/enums.py @@ -4,10 +4,10 @@ "eap_status": models.EAPStatus, "eap_type": models.EAPType, "sector": models.PlannedOperation.Sector, - "timeframe": models.OperationActivity.TimeFrame, - "years_timeframe_value": models.OperationActivity.YearsTimeFrameChoices, - "months_timeframe_value": models.OperationActivity.MonthsTimeFrameChoices, - "days_timeframe_value": models.OperationActivity.DaysTimeFrameChoices, - "hours_timeframe_value": models.OperationActivity.HoursTimeFrameChoices, + "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.EnableApproach.Approach, } diff --git a/eap/factories.py b/eap/factories.py index c431a8518..0f996004a 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -8,9 +8,11 @@ EAPType, EnableApproach, FullEAP, + KeyActor, OperationActivity, PlannedOperation, SimplifiedEAP, + TimeFrame, ) @@ -65,6 +67,9 @@ class Meta: 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) @factory.post_generation def enable_approaches(self, create, extracted, **kwargs): @@ -90,7 +95,7 @@ class Meta: model = OperationActivity activity = fuzzy.FuzzyText(length=50, prefix="Activity-") - timeframe = fuzzy.FuzzyChoice(OperationActivity.TimeFrame) + timeframe = fuzzy.FuzzyChoice(TimeFrame) class EnableApproachFactory(factory.django.DjangoModelFactory): @@ -167,6 +172,13 @@ def early_action_activities(self, create, extracted, **kwargs): 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 diff --git a/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py b/eap/migrations/0009_sourceinformation_and_more.py similarity index 97% rename from eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py rename to eap/migrations/0009_sourceinformation_and_more.py index ffa1622ff..ae0c43d63 100644 --- a/eap/migrations/0009_sourceinformation_alter_simplifiedeap_admin2_and_more.py +++ b/eap/migrations/0009_sourceinformation_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2025-11-26 15:19 +# Generated by Django 4.2.19 on 2025-11-27 05:18 from django.conf import settings from django.db import migrations, models @@ -40,6 +40,25 @@ class Migration(migrations.Migration): "verbose_name_plural": "Source of Information", }, ), + migrations.AddField( + model_name="simplifiedeap", + name="operational_timeframe_unit", + field=models.IntegerField( + choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], + default=20, + verbose_name="Operational Timeframe Unit", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="seap_lead_timeframe_unit", + field=models.IntegerField( + choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], + default=20, + verbose_name="sEAP Lead Timeframe Unit", + ), + preserve_default=False, + ), migrations.AlterField( model_name="simplifiedeap", name="admin2", @@ -69,11 +88,21 @@ class Migration(migrations.Migration): verbose_name="cover image", ), ), + migrations.AlterField( + model_name="simplifiedeap", + name="operational_timeframe", + field=models.IntegerField(verbose_name="Operational Time"), + ), migrations.AlterField( model_name="simplifiedeap", name="people_targeted", field=models.IntegerField(verbose_name="People Targeted."), ), + migrations.AlterField( + model_name="simplifiedeap", + name="seap_lead_time", + field=models.IntegerField(verbose_name="sEAP Lead Time"), + ), migrations.AlterField( model_name="simplifiedeap", name="seap_timeframe", diff --git a/eap/models.py b/eap/models.py index c1e2ef3b4..c0e9ae502 100644 --- a/eap/models.py +++ b/eap/models.py @@ -232,80 +232,84 @@ class Meta: ordering = ["-id"] +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. - 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") - activity = models.CharField(max_length=255, verbose_name=_("Activity")) timeframe = models.IntegerField(choices=TimeFrame.choices, verbose_name=_("Timeframe")) - # TODO(susilnem): Use enums for time_value? time_value = ArrayField( base_field=models.IntegerField(), verbose_name=_("Activity time span"), @@ -950,16 +954,26 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): 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 (Hours)"), - null=True, - blank=True, + 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 Timeframe (Months)"), - null=True, - blank=True, + 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"), @@ -1054,9 +1068,13 @@ def generate_snapshot(self): "modified_by_id": self.modified_by_id, "updated_checklist_file": None, }, - exclude_clone_m2m_fields=[ + exclude_clone_m2m_fields={ "admin2", - ], + "cover_image", + "hazard_impact_images", + "risk_selected_protocols_images", + "selected_early_actions_images", + }, ) # Setting Parent as locked @@ -1438,9 +1456,27 @@ def generate_snapshot(self): "modified_by_id": self.modified_by_id, "updated_checklist_file": None, }, - exclude_clone_m2m_fields=[ + 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 diff --git a/eap/permissions.py b/eap/permissions.py index 7d45668e8..dc75b1e9c 100644 --- a/eap/permissions.py +++ b/eap/permissions.py @@ -31,28 +31,24 @@ def has_permission(self, request, view) -> bool: user = request.user national_society_id = request.data.get("national_society") - return has_country_permission(user=user, national_society_id=national_society_id) + return user.is_superuser or has_country_permission(user=user, national_society_id=national_society_id) class EAPBasePermission(BasePermission): message = "You don't have permission to create/update EAP" - def has_permission(self, request, view) -> bool: + def has_object_permission(self, request, view, obj) -> bool: if request.method not in ["PUT", "PATCH", "POST"]: return True user = request.user - eap_registration = EAPRegistration.objects.filter(id=request.data.get("eap_registration")).first() - - if not eap_registration: - return False + 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 has_country_permission( - user=user, - national_society_id=national_society_id, - ) + return user.is_superuser or has_country_permission(user=user, national_society_id=national_society_id) class EAPValidatedBudgetPermission(BasePermission): diff --git a/eap/serializers.py b/eap/serializers.py index b2a9f2332..e254f6c06 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -13,16 +13,21 @@ UserNameSerializer, ) from eap.models import ( + DaysTimeFrameChoices, EAPFile, EAPRegistration, EAPType, EnableApproach, FullEAP, + HoursTimeFrameChoices, KeyActor, + MonthsTimeFrameChoices, OperationActivity, PlannedOperation, SimplifiedEAP, SourceInformation, + TimeFrame, + YearsTimeFrameChoices, ) from eap.utils import ( has_country_permission, @@ -239,10 +244,10 @@ def validate_file(self, file): ALLOWED_MAP_TIMEFRAMES_VALUE = { - OperationActivity.TimeFrame.YEARS: list(OperationActivity.YearsTimeFrameChoices.values), - OperationActivity.TimeFrame.MONTHS: list(OperationActivity.MonthsTimeFrameChoices.values), - OperationActivity.TimeFrame.DAYS: list(OperationActivity.DaysTimeFrameChoices.values), - OperationActivity.TimeFrame.HOURS: list(OperationActivity.HoursTimeFrameChoices.values), + TimeFrame.YEARS: list(YearsTimeFrameChoices.values), + TimeFrame.MONTHS: list(MonthsTimeFrameChoices.values), + TimeFrame.DAYS: list(DaysTimeFrameChoices.values), + TimeFrame.HOURS: list(HoursTimeFrameChoices.values), } @@ -251,9 +256,10 @@ class OperationActivitySerializer( ): id = serializers.IntegerField(required=False) timeframe = serializers.ChoiceField( - choices=OperationActivity.TimeFrame.choices, + 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, @@ -276,7 +282,7 @@ def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.An raise serializers.ValidationError( { "time_value": gettext("Invalid time_value(s) %s for the selected timeframe %s.") - % (invalid_values, OperationActivity.TimeFrame(timeframe).label) + % (invalid_values, TimeFrame(timeframe).label) } ) return validated_data @@ -376,9 +382,13 @@ class SimplifiedEAPSerializer( ): # FILES - hazard_impact_images_details = EAPFileSerializer(source="hazard_impact_images", many=True, read_only=True) - selected_early_actions_images_details = EAPFileSerializer(source="selected_early_actions_images", many=True, read_only=True) - risk_selected_protocols_images_details = EAPFileSerializer(source="risk_selected_protocols_images", many=True, read_only=True) + hazard_impact_images = EAPFileUpdateSerializer(required=False, many=True) + selected_early_actions_images = EAPFileUpdateSerializer(required=False, many=True) + risk_selected_protocols_images = EAPFileUpdateSerializer(required=False, many=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) class Meta: model = SimplifiedEAP @@ -400,8 +410,59 @@ def validate_selected_early_actions_images(self, images): self.validate_images_field(images) return images + 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]: - eap_registration: EAPRegistration = data["eap_registration"] + 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.") @@ -413,6 +474,7 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: 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.") + self._validate_timeframe(data) return data def create(self, validated_data: dict[str, typing.Any]): @@ -438,35 +500,61 @@ class FullEAPSerializer( evidence_base_source_of_information = SourceInformationSerializer(many=True, required=False) activation_process_source_of_information = SourceInformationSerializer(many=True, required=False) - # FILES - hazard_selection_images_details = EAPFileSerializer(source="hazard_selection_images", many=True, read_only=True) - exposed_element_and_vulnerability_factor_files_details = EAPFileSerializer( - source="exposed_element_and_vulnerability_factor_files", many=True, read_only=True + # IMAGES + hazard_selection_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, ) - prioritized_impact_images_details = EAPFileSerializer(source="prioritized_impact_images", many=True, read_only=True) - risk_analysis_relevant_files_details = EAPFileSerializer(source="risk_analysis_relevant_files", many=True, read_only=True) - forecast_selection_images_details = EAPFileSerializer(source="forecast_selection_images", many=True, read_only=True) - definition_and_justification_impact_level_images_details = EAPFileSerializer( - source="definition_and_justification_impact_level_images", many=True, read_only=True + exposed_element_and_vulnerability_factor_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, ) - identification_of_the_intervention_area_images_details = EAPFileSerializer( - source="identification_of_the_intervention_area_images", many=True, read_only=True + prioritized_impact_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, ) - trigger_model_relevant_files_details = EAPFileSerializer(source="trigger_model_relevant_files", many=True, read_only=True) - early_action_selection_process_images_details = EAPFileSerializer( - source="early_action_selection_process_images", many=True, read_only=True + forecast_selection_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, ) - theory_of_change_table_file_details = EAPFileSerializer(source="theory_of_change_table_file", read_only=True) - evidence_base_relevant_files_details = EAPFileSerializer(source="evidence_base_relevant_files", many=True, read_only=True) - early_action_implementation_images_details = EAPFileSerializer( - source="early_action_implementation_images", many=True, read_only=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, ) - trigger_activation_system_images_details = EAPFileSerializer( - source="trigger_activation_system_images", many=True, read_only=True + early_action_implementation_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, ) - activation_process_relevant_images_details = EAPFileSerializer( - source="activation_process_relevant_images", many=True, read_only=True + trigger_activation_system_images = EAPFileUpdateSerializer( + many=True, + required=False, + allow_null=True, ) + + # FILES + 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) @@ -492,7 +580,12 @@ def validate_prioritized_impact_images(self, images): return images def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: - eap_registration: EAPRegistration = data["eap_registration"] + 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.") diff --git a/eap/test_views.py b/eap/test_views.py index 659327a15..bfd50fd54 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -16,18 +16,22 @@ EAPRegistrationFactory, EnableApproachFactory, FullEAPFactory, + KeyActorFactory, OperationActivityFactory, PlannedOperationFactory, SimplifiedEAPFactory, ) from eap.models import ( + DaysTimeFrameChoices, EAPFile, EAPStatus, EAPType, EnableApproach, - OperationActivity, + MonthsTimeFrameChoices, PlannedOperation, SimplifiedEAP, + TimeFrame, + YearsTimeFrameChoices, ) from main.test_case import APITestCase @@ -233,6 +237,143 @@ def test_update_eap_registration(self): 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, + 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, + 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, + ) + + 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, + ), + ) + 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, + ) + + 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): @@ -307,6 +448,10 @@ def test_create_simplified_eap(self): "budget_file": budget_file.id, "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, @@ -321,28 +466,28 @@ def test_create_simplified_eap(self): "early_action_activities": [ { "activity": "early action activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.ONE_YEAR, - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.ONE_YEAR, + YearsTimeFrameChoices.TWO_YEARS, ], } ], "prepositioning_activities": [ { "activity": "prepositioning activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, - OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, ], } ], "readiness_activities": [ { "activity": "readiness activity", - "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [OperationActivity.YearsTimeFrameChoices.FIVE_YEARS], + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.FIVE_YEARS], } ], } @@ -356,27 +501,27 @@ def test_create_simplified_eap(self): "early_action_activities": [ { "activity": "early action activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, - OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, ], } ], "prepositioning_activities": [ { "activity": "prepositioning activity", - "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [OperationActivity.YearsTimeFrameChoices.THREE_YEARS], + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.THREE_YEARS], } ], "readiness_activities": [ { "activity": "readiness activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.FIVE_YEARS, - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.FIVE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, ], } ], @@ -386,7 +531,7 @@ def test_create_simplified_eap(self): self.authenticate(self.country_admin) response = self.client.post(url, data, format="json") - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, 201, response.data) self.assertEqual( response.data["eap_registration"], @@ -413,41 +558,41 @@ def test_update_simplified_eap(self): ) enable_approach_readiness_operation_activity_1 = OperationActivityFactory.create( activity="Readiness Activity 1", - timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS], + timeframe=TimeFrame.MONTHS, + time_value=[MonthsTimeFrameChoices.ONE_MONTH, MonthsTimeFrameChoices.TWO_MONTHS], ) enable_approach_readiness_operation_activity_2 = OperationActivityFactory.create( activity="Readiness Activity 2", - timeframe=OperationActivity.TimeFrame.YEARS, - time_value=[OperationActivity.YearsTimeFrameChoices.ONE_YEAR, OperationActivity.YearsTimeFrameChoices.FIVE_YEARS], + timeframe=TimeFrame.YEARS, + time_value=[YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.FIVE_YEARS], ) enable_approach_prepositioning_operation_activity_1 = OperationActivityFactory.create( activity="Prepositioning Activity 1", - timeframe=OperationActivity.TimeFrame.MONTHS, + timeframe=TimeFrame.MONTHS, time_value=[ - OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, - OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS, + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.FOUR_MONTHS, ], ) enable_approach_prepositioning_operation_activity_2 = OperationActivityFactory.create( activity="Prepositioning Activity 2", - timeframe=OperationActivity.TimeFrame.MONTHS, + timeframe=TimeFrame.MONTHS, time_value=[ - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, ], ) enable_approach_early_action_operation_activity_1 = OperationActivityFactory.create( activity="Early Action Activity 1", - timeframe=OperationActivity.TimeFrame.DAYS, - time_value=[OperationActivity.DaysTimeFrameChoices.FIVE_DAYS, OperationActivity.DaysTimeFrameChoices.TEN_DAYS], + timeframe=TimeFrame.DAYS, + time_value=[DaysTimeFrameChoices.FIVE_DAYS, DaysTimeFrameChoices.TEN_DAYS], ) enable_approach_early_action_operation_activity_2 = OperationActivityFactory.create( activity="Early Action Activity 2", - timeframe=OperationActivity.TimeFrame.MONTHS, + timeframe=TimeFrame.MONTHS, time_value=[ - OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.ONE_MONTH, + MonthsTimeFrameChoices.THREE_MONTHS, ], ) @@ -472,41 +617,41 @@ def test_update_simplified_eap(self): ) planned_operation_readiness_operation_activity_1 = OperationActivityFactory.create( activity="Readiness Activity 1", - timeframe=OperationActivity.TimeFrame.MONTHS, - time_value=[OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS], + timeframe=TimeFrame.MONTHS, + time_value=[MonthsTimeFrameChoices.ONE_MONTH, MonthsTimeFrameChoices.FOUR_MONTHS], ) planned_operation_readiness_operation_activity_2 = OperationActivityFactory.create( activity="Readiness Activity 2", - timeframe=OperationActivity.TimeFrame.YEARS, - time_value=[OperationActivity.YearsTimeFrameChoices.ONE_YEAR, OperationActivity.YearsTimeFrameChoices.THREE_YEARS], + timeframe=TimeFrame.YEARS, + time_value=[YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.THREE_YEARS], ) planned_operation_prepositioning_operation_activity_1 = OperationActivityFactory.create( activity="Prepositioning Activity 1", - timeframe=OperationActivity.TimeFrame.MONTHS, + timeframe=TimeFrame.MONTHS, time_value=[ - OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, - OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS, + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.FOUR_MONTHS, ], ) planned_operation_prepositioning_operation_activity_2 = OperationActivityFactory.create( activity="Prepositioning Activity 2", - timeframe=OperationActivity.TimeFrame.MONTHS, + timeframe=TimeFrame.MONTHS, time_value=[ - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, ], ) planned_operation_early_action_operation_activity_1 = OperationActivityFactory.create( activity="Early Action Activity 1", - timeframe=OperationActivity.TimeFrame.DAYS, - time_value=[OperationActivity.DaysTimeFrameChoices.FIVE_DAYS, OperationActivity.DaysTimeFrameChoices.TEN_DAYS], + 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=OperationActivity.TimeFrame.MONTHS, + timeframe=TimeFrame.MONTHS, time_value=[ - OperationActivity.MonthsTimeFrameChoices.ONE_MONTH, - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.ONE_MONTH, + MonthsTimeFrameChoices.THREE_MONTHS, ], ) @@ -534,6 +679,10 @@ def test_update_simplified_eap(self): 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, @@ -560,24 +709,24 @@ def test_update_simplified_eap(self): { "id": enable_approach_readiness_operation_activity_1.id, "activity": "Updated Enable Approach Readiness Activity 1", - "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS], + "timeframe": TimeFrame.MONTHS, + "time_value": [MonthsTimeFrameChoices.TWO_MONTHS], } ], "prepositioning_activities": [ { "id": enable_approach_prepositioning_operation_activity_1.id, "activity": "Updated Enable Approach Prepositioning Activity 1", - "timeframe": OperationActivity.TimeFrame.MONTHS, - "time_value": [OperationActivity.MonthsTimeFrameChoices.FOUR_MONTHS], + "timeframe": TimeFrame.MONTHS, + "time_value": [MonthsTimeFrameChoices.FOUR_MONTHS], } ], "early_action_activities": [ { "id": enable_approach_early_action_operation_activity_1.id, "activity": "Updated Enable Approach Early Action Activity 1", - "timeframe": OperationActivity.TimeFrame.DAYS, - "time_value": [OperationActivity.DaysTimeFrameChoices.TEN_DAYS], + "timeframe": TimeFrame.DAYS, + "time_value": [DaysTimeFrameChoices.TEN_DAYS], } ], }, @@ -590,30 +739,30 @@ def test_update_simplified_eap(self): "readiness_activities": [ { "activity": "New Enable Approach Readiness Activity", - "timeframe": OperationActivity.TimeFrame.MONTHS, + "timeframe": TimeFrame.MONTHS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, ], } ], "prepositioning_activities": [ { "activity": "New Enable Approach Prepositioning Activity", - "timeframe": OperationActivity.TimeFrame.MONTHS, + "timeframe": TimeFrame.MONTHS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, - OperationActivity.MonthsTimeFrameChoices.NINE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.NINE_MONTHS, ], } ], "early_action_activities": [ { "activity": "New Enable Approach Early Action Activity", - "timeframe": OperationActivity.TimeFrame.DAYS, + "timeframe": TimeFrame.DAYS, "time_value": [ - OperationActivity.DaysTimeFrameChoices.EIGHT_DAYS, - OperationActivity.DaysTimeFrameChoices.SIXTEEN_DAYS, + DaysTimeFrameChoices.EIGHT_DAYS, + DaysTimeFrameChoices.SIXTEEN_DAYS, ], } ], @@ -630,10 +779,10 @@ def test_update_simplified_eap(self): { "id": planned_operation_readiness_operation_activity_1.id, "activity": "Updated Planned Operation Readiness Activity 1", - "timeframe": OperationActivity.TimeFrame.MONTHS, + "timeframe": TimeFrame.MONTHS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, ], } ], @@ -641,10 +790,10 @@ def test_update_simplified_eap(self): { "id": planned_operation_prepositioning_operation_activity_1.id, "activity": "Updated Planned Operation Prepositioning Activity 1", - "timeframe": OperationActivity.TimeFrame.MONTHS, + "timeframe": TimeFrame.MONTHS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, ], } ], @@ -652,10 +801,10 @@ def test_update_simplified_eap(self): { "id": planned_operation_early_action_operation_activity_1.id, "activity": "Updated Planned Operation Early Action Activity 1", - "timeframe": OperationActivity.TimeFrame.DAYS, + "timeframe": TimeFrame.DAYS, "time_value": [ - OperationActivity.DaysTimeFrameChoices.EIGHT_DAYS, - OperationActivity.DaysTimeFrameChoices.SIXTEEN_DAYS, + DaysTimeFrameChoices.EIGHT_DAYS, + DaysTimeFrameChoices.SIXTEEN_DAYS, ], } ], @@ -669,30 +818,30 @@ def test_update_simplified_eap(self): "readiness_activities": [ { "activity": "New Planned Operation Readiness Activity", - "timeframe": OperationActivity.TimeFrame.MONTHS, + "timeframe": TimeFrame.MONTHS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.THREE_MONTHS, - OperationActivity.MonthsTimeFrameChoices.SIX_MONTHS, + MonthsTimeFrameChoices.THREE_MONTHS, + MonthsTimeFrameChoices.SIX_MONTHS, ], } ], "prepositioning_activities": [ { "activity": "New Planned Operation Prepositioning Activity", - "timeframe": OperationActivity.TimeFrame.MONTHS, + "timeframe": TimeFrame.MONTHS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.TWO_MONTHS, - OperationActivity.MonthsTimeFrameChoices.FIVE_MONTHS, + MonthsTimeFrameChoices.TWO_MONTHS, + MonthsTimeFrameChoices.FIVE_MONTHS, ], } ], "early_action_activities": [ { "activity": "New Planned Operation Early Action Activity", - "timeframe": OperationActivity.TimeFrame.DAYS, + "timeframe": TimeFrame.DAYS, "time_value": [ - OperationActivity.MonthsTimeFrameChoices.FIVE_MONTHS, - OperationActivity.MonthsTimeFrameChoices.TWELVE_MONTHS, + MonthsTimeFrameChoices.FIVE_MONTHS, + MonthsTimeFrameChoices.TWELVE_MONTHS, ], } ], @@ -703,7 +852,7 @@ def test_update_simplified_eap(self): # Authenticate as root user self.authenticate(self.root_user) response = self.client.patch(url, data, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200, response.data) self.assertEqual( response.data["eap_registration"], eap_registration.id, @@ -948,7 +1097,7 @@ def test_status_transition(self): # SUCCESS: As Simplified EAP exists response = self.client.post(self.url, data, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) # NOTE: Transition to NS_ADDRESSING_COMMENTS @@ -976,7 +1125,7 @@ def test_status_transition(self): data["review_checklist_file"] = tmp_file response = self.client.post(self.url, data, format="multipart") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) self.eap_registration.refresh_from_db() @@ -1043,12 +1192,12 @@ def test_status_transition(self): file_data = {"eap_registration": second_snapshot.eap_registration_id, "updated_checklist_file": tmp_file} response = self.client.patch(url, file_data, format="multipart") - self.assertEqual(response.status_code, 200) + 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) + self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS @@ -1069,7 +1218,7 @@ def test_status_transition(self): data["review_checklist_file"] = tmp_file response = self.client.post(self.url, data, format="multipart") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) # Check if three snapshots are created now @@ -1175,7 +1324,7 @@ def test_status_transition(self): 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) + self.assertEqual(response.status_code, 200, response.data) self.eap_registration.refresh_from_db() self.assertIsNotNone( @@ -1187,7 +1336,7 @@ def test_status_transition(self): 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.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.APPROVED) # Check is the approved timeline is added self.eap_registration.refresh_from_db() @@ -1474,28 +1623,28 @@ def test_create_full_eap(self): "early_action_activities": [ { "activity": "early action activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.ONE_YEAR, - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.ONE_YEAR, + YearsTimeFrameChoices.TWO_YEARS, ], } ], "prepositioning_activities": [ { "activity": "prepositioning activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, - OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, ], } ], "readiness_activities": [ { "activity": "readiness activity", - "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [OperationActivity.YearsTimeFrameChoices.FIVE_YEARS], + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.FIVE_YEARS], } ], } @@ -1509,27 +1658,27 @@ def test_create_full_eap(self): "early_action_activities": [ { "activity": "early action activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, - OperationActivity.YearsTimeFrameChoices.THREE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.THREE_YEARS, ], } ], "prepositioning_activities": [ { "activity": "prepositioning activity", - "timeframe": OperationActivity.TimeFrame.YEARS, - "time_value": [OperationActivity.YearsTimeFrameChoices.THREE_YEARS], + "timeframe": TimeFrame.YEARS, + "time_value": [YearsTimeFrameChoices.THREE_YEARS], } ], "readiness_activities": [ { "activity": "readiness activity", - "timeframe": OperationActivity.TimeFrame.YEARS, + "timeframe": TimeFrame.YEARS, "time_value": [ - OperationActivity.YearsTimeFrameChoices.FIVE_YEARS, - OperationActivity.YearsTimeFrameChoices.TWO_YEARS, + YearsTimeFrameChoices.FIVE_YEARS, + YearsTimeFrameChoices.TWO_YEARS, ], } ], @@ -1557,3 +1706,218 @@ def test_create_full_eap(self): # 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, + 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 + enable_approach = EnableApproachFactory.create( + approach=EnableApproach.Approach.SECRETARIAT_SERVICES, + budget_per_approach=5000, + ap_code=123, + indicator_target=500, + ) + 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.enable_approaches.add(enable_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.enable_approaches.all()) + snapshot_approaches = list(snapshot.enable_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, + ) 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 index 8f281722a..dbc72fe18 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,10 +1,9 @@ import os -import typing +from typing import Any, Dict, Set, TypeVar from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError - -from eap.models import FullEAP, SimplifiedEAP +from django.db import models def has_country_permission(user: User, country_id: int) -> bool: @@ -46,34 +45,43 @@ def validate_file_extention(filename: str, allowed_extensions: list[str]): raise ValidationError(f"Invalid uploaded file extension: {extension}, Supported only {allowed_extensions} Files") -# TODO(susilnem): Add typing for FullEAP +T = TypeVar("T", bound=models.Model) def copy_model_instance( - instance: SimplifiedEAP | FullEAP, - overrides: dict[str, typing.Any] | None = None, - exclude_clone_m2m_fields: list[str] | None = None, -) -> SimplifiedEAP | FullEAP: + 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: """ - Creates a copy of a Django model instance, including its many-to-many relationships. - + 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 be copied. - overrides: A dictionary of field names and values to override in the copied instance. - exclude_clone_m2m_fields: A list of many-to-many field names to exclude from copying - + 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: - A new Django model instance that is a copy of the original, with specified overrides - applied and specified many-to-many relationships excluded. + The cloned Django model instance. """ overrides = overrides or {} - exclude_m2m_fields = exclude_clone_m2m_fields 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 = {} + # Cloning standard fields for field in opts.fields: if field.auto_created: continue @@ -81,23 +89,32 @@ def copy_model_instance( data[opts.pk.attname] = None - # NOTE: Apply overrides data.update(overrides) - clone_instance = instance.__class__.objects.create(**data) + clone = 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: - # NOTE: Exclude specified many-to-many fields from cloning but link to original related instances - if m2m_field.name in exclude_m2m_fields: - related_objects = getattr(instance, m2m_field.name).all() - getattr(clone_instance, m2m_field.name).set(related_objects) + 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_objects = getattr(instance, m2m_field.name).all() + related = getattr(instance, name).all() cloned_related = [ - obj.__class__.objects.create(**{f.name: getattr(obj, f.name) for f in obj._meta.fields if not f.auto_created}) - for obj in related_objects + copy_model_instance( + obj, + overrides=None, + exclude_clone_m2m_fields=exclude_m2m, + clone_cache=clone_cache, + ) + for obj in related ] - getattr(clone_instance, m2m_field.name).set(cloned_related) - return clone_instance + getattr(clone, name).set(cloned_related) + + return clone From 0c7d6d8c22e948073888a2d3b1a2627ee37e59c5 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 5 Dec 2025 12:18:54 +0545 Subject: [PATCH 29/57] feat(full-eap): Add new fields on full eap - update test cases - Add actions and impact fields(m2m) --- eap/factories.py | 2 +- ..._eapaction_eapimpact_indicator_and_more.py | 218 ++++++++++++++++++ eap/models.py | 116 ++++++---- eap/serializers.py | 37 ++- eap/test_views.py | 71 +++++- eap/views.py | 42 +++- 6 files changed, 433 insertions(+), 53 deletions(-) create mode 100644 eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py diff --git a/eap/factories.py b/eap/factories.py index 0f996004a..1e2c7a015 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -105,7 +105,6 @@ class Meta: approach = fuzzy.FuzzyChoice(EnableApproach.Approach) budget_per_approach = fuzzy.FuzzyInteger(1000, 1000000) ap_code = fuzzy.FuzzyInteger(100, 999) - indicator_target = fuzzy.FuzzyInteger(10, 1000) @factory.post_generation def readiness_activities(self, create, extracted, **kwargs): @@ -184,6 +183,7 @@ class Meta: model = FullEAP seap_timeframe = fuzzy.FuzzyInteger(5) + 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) diff --git a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py new file mode 100644 index 000000000..500203e2e --- /dev/null +++ b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py @@ -0,0 +1,218 @@ +# Generated by Django 4.2.26 on 2025-12-05 06:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0009_sourceinformation_and_more"), + ] + + 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"), + ), + ], + options={ + "verbose_name": "Early Action", + "verbose_name_plural": "Early Actions", + }, + ), + 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")), + ], + options={ + "verbose_name": " Impact", + "verbose_name_plural": "Expected Impacts", + }, + ), + 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")), + ], + options={ + "verbose_name": "Indicator", + "verbose_name_plural": "Indicators", + }, + ), + migrations.RemoveField( + model_name="enableapproach", + name="indicator_target", + ), + migrations.AddField( + model_name="fulleap", + name="lead_time", + field=models.IntegerField(default=3, verbose_name="Lead Time"), + preserve_default=False, + ), + migrations.AddField( + model_name="fulleap", + name="objective", + field=models.TextField( + default="default", + help_text="Provide an objective statement that describe the main goal of intervention.", + verbose_name="Overall Objective of the EAP.", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="trigger_statement_source_of_information", + field=models.ManyToManyField( + blank=True, + related_name="trigger_statement_source_of_information", + to="eap.sourceinformation", + verbose_name="Trigger Statement Source of Forecast", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="assisted_through_operation", + field=models.TextField(verbose_name="Assisted through the operation"), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="early_action_capability", + field=models.TextField( + help_text="Assumptions or minimum conditions needed to deliver the early actions.", + verbose_name="Experience or Capacity to implement Early Action.", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="overall_objective_intervention", + field=models.TextField( + help_text="Provide an objective statement that describe the main of the intervention.", + verbose_name="Overall objective of the intervention", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="potential_geographical_high_risk_areas", + field=models.TextField( + verbose_name="Potential geographical high-risk areas" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="prioritized_hazard_and_impact", + field=models.TextField( + verbose_name="Prioritized Hazard and its historical impact." + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="rcrc_movement_involvement", + field=models.TextField( + help_text="RCRC Movement partners, Governmental/other agencies consulted/involved.", + verbose_name="RCRC Movement Involvement.", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="risks_selected_protocols", + field=models.TextField(verbose_name="Risk selected for the protocols."), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="selected_early_actions", + field=models.TextField(verbose_name="Selected Early Actions"), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="trigger_threshold_justification", + field=models.TextField( + help_text="Explain how the trigger were set and provide information", + verbose_name="Trigger Threshold Justification", + ), + ), + migrations.AddConstraint( + model_name="fulleap", + constraint=models.UniqueConstraint( + fields=("eap_registration", "version"), name="unique_full_eap_version" + ), + ), + migrations.AddConstraint( + model_name="simplifiedeap", + constraint=models.UniqueConstraint( + fields=("eap_registration", "version"), + name="unique_simplified_eap_version", + ), + ), + migrations.AddField( + model_name="enableapproach", + name="indicators", + field=models.ManyToManyField( + blank=True, + related_name="enable_approach_indicators", + to="eap.indicator", + verbose_name="Enable Approach Indicators", + ), + ), + migrations.AddField( + model_name="fulleap", + name="early_actions", + field=models.ManyToManyField( + related_name="full_eap_early_actions", + to="eap.eapaction", + verbose_name="Early Actions", + ), + ), + migrations.AddField( + model_name="fulleap", + name="prioritized_impacts", + field=models.ManyToManyField( + related_name="full_eap_prioritized_impacts", + to="eap.eapimpact", + verbose_name="Prioritized impacts", + ), + ), + migrations.AddField( + model_name="plannedoperation", + name="indicators", + field=models.ManyToManyField( + blank=True, + related_name="planned_operation_indicators", + to="eap.indicator", + verbose_name="Operation Indicators", + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index c0e9ae502..f8b253b62 100644 --- a/eap/models.py +++ b/eap/models.py @@ -323,12 +323,38 @@ def __str__(self): return f"{self.activity}" -# TODO(susilnem): Verify indicarors? -# class OperationIndicator(models.Model): -# class IndicatorChoices(models.IntegerChoices): -# INDICATOR_1 = 10, _("Indicator 1") -# INDICATOR_2 = 20, _("Indicator 2") -# indicator = models.IntegerField(choices=IndicatorChoices.choices, verbose_name=_("Indicator")) +class Indicator(models.Model): + title = models.CharField(max_length=255, verbose_name=_("Indicator Title")) + target = models.IntegerField(verbose_name=_("Indicator Target")) + + 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")) + + 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")) + + class Meta: + verbose_name = _(" Impact") + verbose_name_plural = _("Expected Impacts") + + def __str__(self): + return f"{self.impact}" class PlannedOperation(models.Model): @@ -353,13 +379,12 @@ class Sector(models.IntegerChoices): budget_per_sector = models.IntegerField(verbose_name=_("Budget per sector (CHF)")) ap_code = models.IntegerField(verbose_name=_("AP Code"), null=True, blank=True) - # TODO(susilnem): verify indicators? - - # indicators = models.ManyToManyField( - # OperationIndicator, - # verbose_name=_("Operation Indicators"), - # blank=True, - # ) + indicators = models.ManyToManyField( + Indicator, + verbose_name=_("Operation Indicators"), + blank=True, + related_name="planned_operation_indicators", + ) # Activities readiness_activities = models.ManyToManyField( @@ -398,14 +423,13 @@ class Approach(models.IntegerChoices): 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) - indicator_target = models.IntegerField(verbose_name=_("Indicator Target"), null=True, blank=True) - # TODO(susilnem): verify indicators? - # indicators = models.ManyToManyField( - # OperationIndicator, - # verbose_name=_("Operation Indicators"), - # blank=True, - # ) + indicators = models.ManyToManyField( + Indicator, + verbose_name=_("Enable Approach Indicators"), + blank=True, + related_name="enable_approach_indicators", + ) # Activities readiness_activities = models.ManyToManyField( @@ -886,8 +910,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): # RISK ANALYSIS # prioritized_hazard_and_impact = models.TextField( verbose_name=_("Prioritized Hazard and its historical impact."), - null=True, - blank=True, ) hazard_impact_images = models.ManyToManyField( EAPFile, @@ -898,8 +920,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): risks_selected_protocols = models.TextField( verbose_name=_("Risk selected for the protocols."), - null=True, - blank=True, ) risk_selected_protocols_images = models.ManyToManyField( @@ -912,8 +932,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): # EARLY ACTION SELECTION # selected_early_actions = models.TextField( verbose_name=_("Selected Early Actions"), - null=True, - blank=True, ) selected_early_actions_images = models.ManyToManyField( EAPFile, @@ -926,20 +944,14 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): 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."), - null=True, - blank=True, ) potential_geographical_high_risk_areas = models.TextField( verbose_name=_("Potential geographical high-risk areas"), - null=True, - blank=True, ) assisted_through_operation = models.TextField( verbose_name=_("Assisted through the operation"), - null=True, - blank=True, ) selection_criteria = models.TextField( verbose_name=_("Selection Criteria."), @@ -977,8 +989,6 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): trigger_threshold_justification = models.TextField( verbose_name=_("Trigger Threshold Justification"), help_text=_("Explain how the trigger were set and provide information"), - null=True, - blank=True, ) next_step_towards_full_eap = models.TextField( verbose_name=_("Next Steps towards Full EAP"), @@ -1006,14 +1016,10 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): 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."), - null=True, - blank=True, ) rcrc_movement_involvement = models.TextField( verbose_name=_("RCRC Movement Involvement."), help_text=_("RCRC Movement partners, Governmental/other agencies consulted/involved."), - null=True, - blank=True, ) # NOTE: Snapshot fields @@ -1046,6 +1052,12 @@ 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}" @@ -1093,6 +1105,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): related_name="full_eap", ) + 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."), @@ -1155,6 +1172,12 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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"), @@ -1182,9 +1205,12 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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 Information"), + verbose_name=_("Trigger Statement Source of Forecast"), related_name="trigger_statement_source_of_information", blank=True, ) @@ -1244,6 +1270,12 @@ class FullEAP(EAPBaseModel, CommonEAPFields): # 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"), ) @@ -1435,6 +1467,12 @@ 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}" diff --git a/eap/serializers.py b/eap/serializers.py index e254f6c06..dbdd40265 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -14,12 +14,14 @@ ) from eap.models import ( DaysTimeFrameChoices, + EAPAction, EAPFile, EAPRegistration, EAPType, EnableApproach, FullEAP, HoursTimeFrameChoices, + Indicator, KeyActor, MonthsTimeFrameChoices, OperationActivity, @@ -288,6 +290,16 @@ def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.An return validated_data +class IndicatorSerializer( + serializers.ModelSerializer, +): + id = serializers.IntegerField(required=False) + + class Meta: + model = Indicator + fields = "__all__" + + class PlannedOperationSerializer( NestedUpdateMixin, NestedCreateMixin, @@ -295,6 +307,8 @@ class PlannedOperationSerializer( ): id = serializers.IntegerField(required=False) + indicators = IndicatorSerializer(many=True, required=True) + # activities readiness_activities = OperationActivitySerializer(many=True, required=True) prepositioning_activities = OperationActivitySerializer(many=True, required=True) @@ -312,6 +326,8 @@ class EnableApproachSerializer( ): id = serializers.IntegerField(required=False) + indicators = IndicatorSerializer(many=True, required=True) + # activities readiness_activities = OperationActivitySerializer(many=True, required=True) prepositioning_activities = OperationActivitySerializer(many=True, required=True) @@ -347,6 +363,22 @@ class Meta: fields = "__all__" +class ActionSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = EAPAction + fields = "__all__" + + +class ImpactSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = EAPAction + fields = "__all__" + + class CommonEAPFieldsSerializer(serializers.ModelSerializer): MAX_NUMBER_OF_IMAGES = 5 @@ -493,7 +525,10 @@ class FullEAPSerializer( # admins key_actors = KeyActorSerializer(many=True, required=True) - # SOURCE OF INFOMATIONS + early_actions = ActionSerializer(many=True, required=False) + prioritized_impacts = ImpactSerializer(many=True, required=False) + + # SOURCE OF INFORMATIONS risk_analysis_source_of_information = SourceInformationSerializer(many=True, required=False) trigger_statement_source_of_information = SourceInformationSerializer(many=True, required=False) trigger_model_source_of_information = SourceInformationSerializer(many=True, required=False) diff --git a/eap/test_views.py b/eap/test_views.py index bfd50fd54..5f15c0051 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -445,6 +445,15 @@ def test_create_simplified_eap(self): ) data = { "eap_registration": eap_registration.id, + "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, "total_budget": 10000, "seap_timeframe": 3, @@ -463,6 +472,16 @@ def test_create_simplified_eap(self): "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", @@ -497,7 +516,16 @@ def test_create_simplified_eap(self): "ap_code": 11, "approach": EnableApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, - "indicator_target": 10000, + "indicators": [ + { + "title": "indicator enable approach 1", + "target": 100, + }, + { + "title": "indicator enable approach 2", + "target": 200, + }, + ], "early_action_activities": [ { "activity": "early action activity", @@ -601,7 +629,6 @@ def test_update_simplified_eap(self): approach=EnableApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, ap_code=123, - indicator_target=500, readiness_activities=[ enable_approach_readiness_operation_activity_1.id, enable_approach_readiness_operation_activity_2.id, @@ -704,7 +731,6 @@ def test_update_simplified_eap(self): "approach": EnableApproach.Approach.NATIONAL_SOCIETY_STRENGTHENING, "budget_per_approach": 8000, "ap_code": 123, - "indicator_target": 800, "readiness_activities": [ { "id": enable_approach_readiness_operation_activity_1.id, @@ -735,7 +761,6 @@ def test_update_simplified_eap(self): "approach": EnableApproach.Approach.PARTNERSHIP_AND_COORDINATION, "budget_per_approach": 9000, "ap_code": 124, - "indicator_target": 900, "readiness_activities": [ { "activity": "New Enable Approach Readiness Activity", @@ -775,6 +800,16 @@ def test_update_simplified_eap(self): "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, @@ -873,24 +908,20 @@ def test_update_simplified_eap(self): response.data["enable_approaches"][0]["approach"], response.data["enable_approaches"][0]["budget_per_approach"], response.data["enable_approaches"][0]["ap_code"], - response.data["enable_approaches"][0]["indicator_target"], # NEW DATA response.data["enable_approaches"][1]["approach"], response.data["enable_approaches"][1]["budget_per_approach"], response.data["enable_approaches"][1]["ap_code"], - response.data["enable_approaches"][1]["indicator_target"], }, { enable_approach.id, data["enable_approaches"][0]["approach"], data["enable_approaches"][0]["budget_per_approach"], data["enable_approaches"][0]["ap_code"], - data["enable_approaches"][0]["indicator_target"], # NEW DATA data["enable_approaches"][1]["approach"], data["enable_approaches"][1]["budget_per_approach"], data["enable_approaches"][1]["ap_code"], - data["enable_approaches"][1]["indicator_target"], }, ) self.assertEqual( @@ -1568,6 +1599,8 @@ def test_create_full_eap(self): "eap_registration": eap_registration.id, "budget_file": budget_file_instance.id, "total_budget": 10000, + "objective": "FUll eap objective", + "lead_time": 5, "seap_timeframe": 5, "readiness_budget": 3000, "pre_positioning_budget": 4000, @@ -1620,6 +1653,16 @@ def test_create_full_eap(self): "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", @@ -1654,7 +1697,16 @@ def test_create_full_eap(self): "ap_code": 11, "approach": EnableApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, - "indicator_target": 10000, + "indicators": [ + { + "title": "indicator enable approach 1", + "target": 300, + }, + { + "title": "indicator enable approach 2", + "target": 400, + }, + ], "early_action_activities": [ { "activity": "early action activity", @@ -1784,7 +1836,6 @@ def test_snapshot_full_eap(self): approach=EnableApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, ap_code=123, - indicator_target=500, ) hazard_selection_image_1 = EAPFileFactory._create_file( created_by=self.user, diff --git a/eap/views.py b/eap/views.py index 3194f7aab..43a921e23 100644 --- a/eap/views.py +++ b/eap/views.py @@ -16,8 +16,10 @@ EAPRegistration, EAPStatus, EAPType, + EnableApproach, FullEAP, KeyActor, + PlannedOperation, SimplifiedEAP, ) from eap.permissions import ( @@ -194,8 +196,24 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: "hazard_impact_images", "risk_selected_protocols_images", "selected_early_actions_images", - "planned_operations", - "enable_approaches", + Prefetch( + "planned_operations", + queryset=PlannedOperation.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), + Prefetch( + "enable_approaches", + queryset=EnableApproach.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), ) ) @@ -222,6 +240,8 @@ def get_queryset(self) -> QuerySet[FullEAP]: ) .prefetch_related( "admin2", + "prioritized_impacts", + "early_actions", # source information "risk_analysis_source_of_information", "trigger_statement_source_of_information", @@ -249,6 +269,24 @@ def get_queryset(self) -> QuerySet[FullEAP]: "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( + "enable_approaches", + queryset=EnableApproach.objects.prefetch_related( + "indicators", + "readiness_activities", + "prepositioning_activities", + "early_action_activities", + ), + ), ) ) From 6db57d36f26c15a6fc63c3f1163784496b58e182 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 10 Dec 2025 14:16:20 +0545 Subject: [PATCH 30/57] feat(full-eap): Add new status and update on status transition - Add latest eap on registration - Update on status transition --- eap/factories.py | 4 + ..._eapaction_eapimpact_indicator_and_more.py | 72 ++++++- eap/models.py | 39 +++- eap/serializers.py | 168 +++++++++++------ eap/test_views.py | 175 ++++++++++++++++-- eap/views.py | 21 ++- 6 files changed, 389 insertions(+), 90 deletions(-) diff --git a/eap/factories.py b/eap/factories.py index 1e2c7a015..90f3dd1f3 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -1,4 +1,7 @@ +from datetime import datetime + import factory +import pytz from factory import fuzzy from eap.models import ( @@ -183,6 +186,7 @@ class Meta: model = FullEAP seap_timeframe = fuzzy.FuzzyInteger(5) + 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) diff --git a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py index 500203e2e..4a5032a80 100644 --- a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py +++ b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py @@ -1,6 +1,8 @@ -# Generated by Django 4.2.26 on 2025-12-05 06:19 +# Generated by Django 4.2.26 on 2025-12-10 04:38 from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone class Migration(migrations.Migration): @@ -73,14 +75,62 @@ class Migration(migrations.Migration): "verbose_name_plural": "Indicators", }, ), + migrations.RemoveField( + model_name="eapregistration", + name="pfa_signed_at", + ), migrations.RemoveField( model_name="enableapproach", name="indicator_target", ), + 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="pending_pfa_at", + field=models.DateTimeField( + blank=True, + help_text="Timestamp when the EAP was marked as pending PFA.", + null=True, + verbose_name="pending pfa at", + ), + ), + migrations.AddField( + model_name="fulleap", + name="expected_submission_time", + field=models.DateField( + default=django.utils.timezone.now, + help_text="Include the propose time of submission, accounting for the time it will take to deliver the application.", + verbose_name="Expected submission time", + ), + preserve_default=False, + ), migrations.AddField( model_name="fulleap", name="lead_time", - field=models.IntegerField(default=3, verbose_name="Lead Time"), + field=models.IntegerField(default=1, verbose_name="Lead Time"), preserve_default=False, ), migrations.AddField( @@ -93,6 +143,24 @@ class Migration(migrations.Migration): ), preserve_default=False, ), + migrations.AlterField( + model_name="eapregistration", + name="status", + field=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", + ), + ), migrations.AlterField( model_name="fulleap", name="trigger_statement_source_of_information", diff --git a/eap/models.py b/eap/models.py index f8b253b62..1349edf58 100644 --- a/eap/models.py +++ b/eap/models.py @@ -523,16 +523,18 @@ class EAPStatus(models.IntegerChoices): 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. """ - APPROVED = 50, _("Approved") + 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. """ - PFA_SIGNED = 60, _("PFA Signed") - """EAP should be APPROVED before changing to this status.""" - ACTIVATED = 70, _("Activated") """EAP has been activated""" @@ -615,6 +617,24 @@ class EAPRegistration(EAPBaseModel): 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( @@ -659,11 +679,11 @@ class EAPRegistration(EAPBaseModel): verbose_name=_("approved at"), help_text=_("Timestamp when the EAP was approved."), ) - pfa_signed_at = models.DateTimeField( + pending_pfa_at = models.DateTimeField( null=True, blank=True, - verbose_name=_("PFA signed at"), - help_text=_("Timestamp when the PFA was signed."), + verbose_name=_("pending pfa at"), + help_text=_("Timestamp when the EAP was marked as pending PFA."), ) activated_at = models.DateTimeField( null=True, @@ -1105,6 +1125,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): related_name="full_eap", ) + 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."), diff --git a/eap/serializers.py b/eap/serializers.py index dbdd40265..bc3bfcf54 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -16,6 +16,7 @@ DaysTimeFrameChoices, EAPAction, EAPFile, + EAPImpact, EAPRegistration, EAPType, EnableApproach, @@ -45,7 +46,7 @@ class BaseEAPSerializer(serializers.ModelSerializer): def get_fields(self): fields = super().get_fields() - # NOTE: Setting `created_by` and `modified_by` required to Flase + # NOTE: Setting `created_by` and `modified_by` required to False fields["created_by"] = serializers.PrimaryKeyRelatedField( queryset=User.objects.all(), required=False, @@ -174,6 +175,8 @@ class Meta: "modified_at", "created_by", "modified_by", + "latest_simplified_eap", + "latest_full_eap", ] def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: @@ -375,7 +378,7 @@ class ImpactSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) class Meta: - model = EAPAction + model = EAPImpact fields = "__all__" @@ -399,9 +402,12 @@ def get_fields(self): fields["budget_file_details"] = EAPFileSerializer(source="budget_file", read_only=True) return fields - def validate_images_field(self, images): + def validate_images_field(self, field_name, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: - raise serializers.ValidationError(f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed.") + raise serializers.ValidationError( + {field_name: [f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed."]}, + ) + validate_file_type(images) return images @@ -422,6 +428,17 @@ class SimplifiedEAPSerializer( 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 = [ @@ -430,18 +447,6 @@ class Meta: ] exclude = ("cover_image",) - def validate_hazard_impact_images(self, images): - self.validate_images_field(images) - return images - - def validate_risk_selected_protocols_images(self, images): - self.validate_images_field(images) - return images - - def validate_selected_early_actions_images(self, images): - self.validate_images_field(images) - return images - def _validate_timeframe(self, data: dict[str, typing.Any]) -> None: # --- seap lead TimeFrame --- seap_unit = data.get("seap_lead_timeframe_unit") @@ -506,12 +511,21 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: 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 @@ -535,6 +549,21 @@ class FullEAPSerializer( evidence_base_source_of_information = SourceInformationSerializer(many=True, required=False) activation_process_source_of_information = SourceInformationSerializer(many=True, required=False) + # 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", + ] + # IMAGES hazard_selection_images = EAPFileUpdateSerializer( many=True, @@ -601,19 +630,6 @@ class Meta: ) exclude = ("cover_image",) - # TODO(susilnem): Add validation for multiple image fields similar to SimplifiedEAP - def validate_hazard_selection_images(self, images): - self.validate_images_field(images) - return images - - def validate_exposed_element_and_vulnerability_factor_files(self, images): - self.validate_images_field(images) - return images - - def validate_prioritized_impact_images(self, images): - self.validate_images_field(images) - return images - 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) @@ -632,8 +648,20 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: 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( @@ -643,15 +671,17 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: ] ) + 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.APPROVED), - (EAPRegistration.Status.APPROVED, EAPRegistration.Status.PFA_SIGNED), - (EAPRegistration.Status.PFA_SIGNED, EAPRegistration.Status.ACTIVATED), + (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), ] ) @@ -688,10 +718,12 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % (EAPRegistration.Status(current_status).label, EAPRegistration.Status(new_status).label) ) - if (current_status, new_status) == ( - EAPRegistration.Status.UNDER_REVIEW, - EAPRegistration.Status.NS_ADDRESSING_COMMENTS, - ): + # 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 @@ -703,15 +735,15 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) - # latest Simplified EAP - eap_instance = SimplifiedEAP.objects.filter(eap_registration=self.instance).order_by("-version").first() - - # If no Simplified EAP, check for Full EAP - if not eap_instance: - eap_instance = FullEAP.objects.filter(eap_registration=self.instance).order_by("-version").first() - - assert eap_instance is not None, "EAP instance does not exist." - eap_instance.generate_snapshot() + # 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 + 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 + self.instance.save(update_fields=["latest_full_eap"]) elif (current_status, new_status) == ( EAPRegistration.Status.UNDER_REVIEW, @@ -739,24 +771,38 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) - latest_simplified_eap: SimplifiedEAP | None = ( - SimplifiedEAP.objects.filter( - eap_registration=self.instance, + # 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 + ) + 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 + ) + + elif (current_status, new_status) == ( + 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 ) - .order_by("-version") - .first() - ) - # TODO(susilnem): Add checks for FULL EAP - if not (latest_simplified_eap and latest_simplified_eap.updated_checklist_file): + if not validated_data.get("review_checklist_file"): raise serializers.ValidationError( - gettext("NS Addressing Comments file must be uploaded before changing status to %s.") + gettext("Review checklist file must be uploaded before changing status to %s.") % EAPRegistration.Status(new_status).label ) elif (current_status, new_status) == ( EAPRegistration.Status.TECHNICALLY_VALIDATED, - EAPRegistration.Status.APPROVED, + EAPRegistration.Status.PENDING_PFA, ): if not is_user_ifrc_admin(user): raise PermissionDenied( @@ -770,27 +816,27 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ) # Update timestamp - self.instance.approved_at = timezone.now() + self.instance.pending_pfa_at = timezone.now() self.instance.save( update_fields=[ - "approved_at", + "pending_pfa_at", ] ) elif (current_status, new_status) == ( + EAPRegistration.Status.PENDING_PFA, EAPRegistration.Status.APPROVED, - EAPRegistration.Status.PFA_SIGNED, ): # Update timestamp - self.instance.pfa_signed_at = timezone.now() + self.instance.approved_at = timezone.now() self.instance.save( update_fields=[ - "pfa_signed_at", + "approved_at", ] ) elif (current_status, new_status) == ( - EAPRegistration.Status.PFA_SIGNED, + EAPRegistration.Status.APPROVED, EAPRegistration.Status.ACTIVATED, ): # Update timestamp diff --git a/eap/test_views.py b/eap/test_views.py index 5f15c0051..1225827e9 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -570,6 +570,13 @@ def test_create_simplified_eap(self): 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) @@ -1078,7 +1085,6 @@ def setUp(self): ) self.url = f"/api/v2/eap-registration/{self.eap_registration.id}/status/" - # TODO(susilnem): Update test case for file uploads once implemented def test_status_transition(self): # Create permissions management.call_command("make_permissions") @@ -1125,6 +1131,8 @@ def test_status_transition(self): 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") @@ -1201,6 +1209,11 @@ def test_status_transition(self): second_snapshot.updated_checklist_file.name, "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 @@ -1263,14 +1276,14 @@ def test_status_transition(self): ) # Check version of the latest snapshot - # Version should be 2 + # 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 2.", + "Latest snapshot version should be 3.", ) # Check for parent_id self.assertEqual( @@ -1288,6 +1301,13 @@ def test_status_transition(self): "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 = { @@ -1335,10 +1355,127 @@ def test_status_transition(self): 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.name, + "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}/" + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Updated Test content") + tmp_file.seek(0) + + 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": tmp_file, + } + + response = self.client.patch(url, file_data, format="multipart") + 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 -> APPROVED + # TECHNICALLY_VALIDATED -> PENDING_PFA data = { - "status": EAPStatus.APPROVED, + "status": EAPStatus.PENDING_PFA, } # LOGIN as country admin user @@ -1364,19 +1501,19 @@ def test_status_transition(self): # LOGIN as IFRC admin user # SUCCESS: As only ifrc admins or superuser can - self.assertIsNone(self.eap_registration.approved_at) + 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.APPROVED) + 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.approved_at) + self.assertIsNotNone(self.eap_registration.pending_pfa_at) - # NOTE: Transition to PFA_SIGNED - # APPROVED -> PFA_SIGNED + # NOTE: Transition to APPROVED + # PENDING_PFA -> APPROVED data = { - "status": EAPStatus.PFA_SIGNED, + "status": EAPStatus.APPROVED, } # LOGIN as country admin user @@ -1387,17 +1524,17 @@ def test_status_transition(self): # LOGIN as IFRC admin user # SUCCESS: As only ifrc admins or superuser can - self.assertIsNone(self.eap_registration.activated_at) + 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.PFA_SIGNED) + 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.pfa_signed_at) + self.assertIsNotNone(self.eap_registration.approved_at) # NOTE: Transition to ACTIVATED - # PFA_SIGNED -> ACTIVATED + # APPROVED -> ACTIVATED data = { "status": EAPStatus.ACTIVATED, } @@ -1601,6 +1738,7 @@ def test_create_full_eap(self): "total_budget": 10000, "objective": "FUll eap objective", "lead_time": 5, + "expected_submission_time": "2024-12-31", "seap_timeframe": 5, "readiness_budget": 3000, "pre_positioning_budget": 4000, @@ -1755,6 +1893,13 @@ def test_create_full_eap(self): "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) diff --git a/eap/views.py b/eap/views.py index 43a921e23..71e8027d7 100644 --- a/eap/views.py +++ b/eap/views.py @@ -185,17 +185,16 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: .select_related( "created_by", "modified_by", - "cover_image", - "budget_file", + "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", "admin2", - "hazard_impact_images", - "risk_selected_protocols_images", - "selected_early_actions_images", Prefetch( "planned_operations", queryset=PlannedOperation.objects.prefetch_related( @@ -214,6 +213,18 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: "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"), + ), ) ) From 1ccb096de951b83eda598bb87e1c5ebcd6148142 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 10 Dec 2025 14:52:16 +0545 Subject: [PATCH 31/57] feat(full-eap): Add new field forecast table file --- eap/admin.py | 1 + ..._eapaction_eapimpact_indicator_and_more.py | 14 ++++++- eap/models.py | 9 +++++ eap/serializers.py | 37 +++++++++++-------- eap/test_views.py | 6 +++ eap/views.py | 1 + 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/eap/admin.py b/eap/admin.py index 939a94136..af6d0b1e0 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -141,6 +141,7 @@ class FullEAPAdmin(admin.ModelAdmin): "activation_process_relevant_files", "meal_relevant_files", "capacity_relevant_files", + "forecast_table_file", ) def get_queryset(self, request): diff --git a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py index 4a5032a80..ce96d5f83 100644 --- a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py +++ b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2025-12-10 04:38 +# Generated by Django 4.2.26 on 2025-12-10 08:52 from django.db import migrations, models import django.db.models.deletion @@ -127,6 +127,18 @@ class Migration(migrations.Migration): ), preserve_default=False, ), + migrations.AddField( + model_name="fulleap", + name="forecast_table_file", + field=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", + ), + ), migrations.AddField( model_name="fulleap", name="lead_time", diff --git a/eap/models.py b/eap/models.py index 1349edf58..f765a5e18 100644 --- a/eap/models.py +++ b/eap/models.py @@ -1245,6 +1245,15 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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"), diff --git a/eap/serializers.py b/eap/serializers.py index bc3bfcf54..f4cb3d5e1 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -549,21 +549,6 @@ class FullEAPSerializer( evidence_base_source_of_information = SourceInformationSerializer(many=True, required=False) activation_process_source_of_information = SourceInformationSerializer(many=True, required=False) - # 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", - ] - # IMAGES hazard_selection_images = EAPFileUpdateSerializer( many=True, @@ -612,6 +597,13 @@ class FullEAPSerializer( ) # 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) @@ -622,6 +614,21 @@ class FullEAPSerializer( 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 = ( diff --git a/eap/test_views.py b/eap/test_views.py index 1225827e9..b39e2a895 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1732,9 +1732,15 @@ def test_create_full_eap(self): 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, + ) + data = { "eap_registration": eap_registration.id, "budget_file": budget_file_instance.id, + "forecast_table_file": forecast_table_file.id, "total_budget": 10000, "objective": "FUll eap objective", "lead_time": 5, diff --git a/eap/views.py b/eap/views.py index 71e8027d7..ce845b2c3 100644 --- a/eap/views.py +++ b/eap/views.py @@ -276,6 +276,7 @@ def get_queryset(self) -> QuerySet[FullEAP]: "activation_process_relevant_files", "meal_relevant_files", "capacity_relevant_files", + "forecast_table_file", Prefetch( "key_actors", queryset=KeyActor.objects.select_related("national_society"), From 3c4c8f20229b1a05476421245212f11020c72aaf Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 11 Dec 2025 15:36:51 +0545 Subject: [PATCH 32/57] chore(eap): Update on active eaps endpoint --- eap/serializers.py | 2 -- eap/test_views.py | 9 +++++++++ eap/views.py | 19 +++---------------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/eap/serializers.py b/eap/serializers.py index f4cb3d5e1..c85abbf7f 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -407,8 +407,6 @@ def validate_images_field(self, field_name, images): raise serializers.ValidationError( {field_name: [f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed."]}, ) - - validate_file_type(images) return images diff --git a/eap/test_views.py b/eap/test_views.py index b39e2a895..81693f108 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -260,6 +260,7 @@ def test_active_eaps(self): ) 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], @@ -270,6 +271,7 @@ def test_active_eaps(self): 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], @@ -316,6 +318,8 @@ def test_active_eaps(self): 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, @@ -327,6 +331,9 @@ def test_active_eaps(self): 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, @@ -354,6 +361,8 @@ def test_active_eaps(self): 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() diff --git a/eap/views.py b/eap/views.py index ce845b2c3..c3fccf92e 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,6 +1,5 @@ # Create your views here. -from django.db.models import Case, IntegerField, Subquery, When -from django.db.models.expressions import OuterRef +from django.db.models import Case, F, IntegerField, When from django.db.models.query import Prefetch, QuerySet from drf_spectacular.utils import extend_schema from rest_framework import mixins, permissions, response, status, viewsets @@ -58,18 +57,6 @@ class ActiveEAPViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): filterset_class = EAPRegistrationFilterSet def get_queryset(self) -> QuerySet[EAPRegistration]: - latest_simplified_eap = ( - SimplifiedEAP.objects.filter(eap_registration=OuterRef("id"), is_locked=False) - .order_by("-version") - .values("total_budget")[:1] - ) - - latest_full_eap = ( - FullEAP.objects.filter(eap_registration=OuterRef("id"), is_locked=False) - .order_by("-version") - .values("total_budget")[:1] - ) - return ( super() .get_queryset() @@ -79,11 +66,11 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: requirement_cost=Case( When( eap_type=EAPType.SIMPLIFIED_EAP, - then=Subquery(latest_simplified_eap), + then=F("latest_simplified_eap__total_budget"), ), When( eap_type=EAPType.FULL_EAP, - then=Subquery(latest_full_eap), + then=F("latest_full_eap__total_budget"), ), output_field=IntegerField(), ) From 7104a7c1f834745e8577a1ef230a8aa3a11f12de Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 11:18:26 +0545 Subject: [PATCH 33/57] feat(eap): Add multiple validation checks for files --- eap/serializers.py | 23 ++++++++++++++ eap/test_views.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/eap/serializers.py b/eap/serializers.py index c85abbf7f..767b3af43 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -243,6 +243,13 @@ class Meta: "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 @@ -395,6 +402,7 @@ class CommonEAPFieldsSerializer(serializers.ModelSerializer): def get_fields(self): fields = super().get_fields() + # 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=False) @@ -402,6 +410,21 @@ def get_fields(self): fields["budget_file_details"] = EAPFileSerializer(source="budget_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.name, ALLOWED_FILE_EXTENTIONS) + validate_file_type(file) + return file + def validate_images_field(self, field_name, images): if images and len(images) > self.MAX_NUMBER_OF_IMAGES: raise serializers.ValidationError( diff --git a/eap/test_views.py b/eap/test_views.py index 81693f108..4ea1befc5 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -452,6 +452,16 @@ def test_create_simplified_eap(self): 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, "prioritized_hazard_and_impact": "Floods with potential heavy impact.", @@ -464,6 +474,26 @@ def test_create_simplified_eap(self): "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, @@ -1746,10 +1776,56 @@ def test_create_full_eap(self): 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, "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, From 3e37af1cc0e203616f204c1d8f94633fdad90591 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 4 Dec 2025 14:27:30 +0545 Subject: [PATCH 34/57] chore(assest): Update asset commit head --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index e1f47e4bd..d5c165961 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit e1f47e4bd5bb1e622f185d9d7ccd759fde724ac4 +Subproject commit d5c1659610b42aee2e84dfdad35768b0cbc7c7f2 From 336cb6aed329af389c8a18af3de04a25e8731491 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 4 Dec 2025 15:28:19 +0545 Subject: [PATCH 35/57] fix(eap-export): Update Export url for EAP --- .../0229_alter_export_export_type.py | 18 +++++++++++ api/models.py | 4 +-- api/serializers.py | 31 +++++++++++++++++-- api/tasks.py | 2 ++ eap/test_views.py | 8 ++--- 5 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 api/migrations/0229_alter_export_export_type.py diff --git a/api/migrations/0229_alter_export_export_type.py b/api/migrations/0229_alter_export_export_type.py new file mode 100644 index 000000000..db4e69525 --- /dev/null +++ b/api/migrations/0229_alter_export_export_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-12-04 09:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0228_alter_export_export_type'), + ] + + 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 398e141e5..358e7a6c4 100644 --- a/api/models.py +++ b/api/models.py @@ -2564,8 +2564,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-eap", _("Simplified EAP") - FULL_EAP = "full-eap", _("Full EAP") + 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/serializers.py b/api/serializers.py index baba5d0ae..742d3f337 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2546,6 +2546,11 @@ 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) + # NOTE: Version of a EAP export being requested, only applicable for full and simplified EAP exports + version = serializers.IntegerField(required=False, write_only=True) class Meta: model = Export @@ -2561,6 +2566,7 @@ def create(self, validated_data): export_id = validated_data.get("export_id") export_type = validated_data.get("export_type") country_id = validated_data.get("per_country") + version = validated_data.get("version", None) if export_type == Export.ExportType.DREF: title = Dref.objects.filter(id=export_id).first().title elif export_type == Export.ExportType.OPS_UPDATE: @@ -2571,12 +2577,20 @@ def create(self, validated_data): 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: - simplified_eap = SimplifiedEAP.objects.filter(id=export_id).first() + if version: + simplified_eap = ( + SimplifiedEAP.objects.filter(eap_registration__id=export_id, version=version).order_by("-version").first() + ) + else: + simplified_eap = SimplifiedEAP.objects.filter(eap_registration__id=export_id).order_by("-version").first() title = ( f"{simplified_eap.eap_registration.national_society.name}-{simplified_eap.eap_registration.disaster_type.name}" ) elif export_type == Export.ExportType.FULL_EAP: - full_eap = FullEAP.objects.filter(id=export_id).first() + if version: + full_eap = FullEAP.objects.filter(eap_registration__id=export_id, version=version).order_by("-version").first() + else: + full_eap = FullEAP.objects.filter(eap_registration__id=export_id).order_by("-version").first() title = f"{full_eap.eap_registration.national_society.name}-{full_eap.eap_registration.disaster_type.name}" else: title = "Export" @@ -2584,6 +2598,19 @@ def create(self, validated_data): if export_type == Export.ExportType.PER: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/countries/{country_id}/{export_type}/{export_id}/export/" + + if export_type in [ + Export.ExportType.SIMPLIFIED_EAP, + Export.ExportType.FULL_EAP, + ]: + validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/eap/{export_id}/{export_type}/export/" + # NOTE: EAP exports with diff view only for EAPs exports + diff = validated_data.pop("diff") + if diff: + validated_data["url"] += "?diff=true" + if version: + validated_data["url"] += f"&version={version}" if diff else f"?version={version}" + else: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/{export_type}/{export_id}/export/" diff --git a/api/tasks.py b/api/tasks.py index c551d7507..11bcf357f 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -122,6 +122,8 @@ def generate_url(url, export_id, user, title, language): file_name = f'SIMPLIFIED EAP {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' elif export.export_type == Export.ExportType.FULL_EAP: file_name = f'FULL EAP {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' + elif export.export_type == Export.ExportType.EAP_REGISTRATION: + file_name = f'EAP REGISTRATION {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( diff --git a/eap/test_views.py b/eap/test_views.py index 4ea1befc5..85b345b94 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1641,7 +1641,7 @@ def test_simplified_eap_export(self, mock_generate_url): ) data = { "export_type": Export.ExportType.SIMPLIFIED_EAP, - "export_id": self.simplified_eap.id, + "export_id": self.eap_registration.id, "is_pga": False, } @@ -1652,7 +1652,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = f"{settings.GO_WEB_INTERNAL_URL}/{Export.ExportType.SIMPLIFIED_EAP}/{self.simplified_eap.id}/export/" + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{self.eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/" self.assertEqual(response.data["url"], expected_url) self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) @@ -1679,7 +1679,7 @@ def test_full_eap_export(self, mock_generate_url): ) data = { "export_type": Export.ExportType.FULL_EAP, - "export_id": self.full_eap.id, + "export_id": self.eap_registration.id, "is_pga": False, } @@ -1689,7 +1689,7 @@ def test_full_eap_export(self, mock_generate_url): 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}/{Export.ExportType.FULL_EAP}/{self.full_eap.id}/export/" + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{self.eap_registration.id}/{Export.ExportType.FULL_EAP}/export/" self.assertEqual(response.data["url"], expected_url) self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) From 5f3e2c694a819496ca86cce3b7d256035bc4e4ac Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 5 Dec 2025 16:34:34 +0545 Subject: [PATCH 36/57] feat(eap): Add diff and version tracking for pdf export - Update logic for the diff and latest eaps --- .../0229_alter_export_export_type.py | 21 +++-- api/serializers.py | 43 +++++---- api/tasks.py | 2 - eap/test_views.py | 89 ++++++++++++++++++- 4 files changed, 129 insertions(+), 26 deletions(-) diff --git a/api/migrations/0229_alter_export_export_type.py b/api/migrations/0229_alter_export_export_type.py index db4e69525..1e89038e5 100644 --- a/api/migrations/0229_alter_export_export_type.py +++ b/api/migrations/0229_alter_export_export_type.py @@ -4,15 +4,26 @@ class Migration(migrations.Migration): - dependencies = [ - ('api', '0228_alter_export_export_type'), + ("api", "0228_alter_export_export_type"), ] 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'), + 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/serializers.py b/api/serializers.py index 742d3f337..594159195 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2561,12 +2561,25 @@ def validate_pdf_file(self, pdf_file): validate_file_type(pdf_file) return pdf_file + def get_latest(self, model: type[SimplifiedEAP | FullEAP], eap_registration_id: int, version: int | None = None): + """ + Get the latest version of the EAP (Simplified or Full) based on the eap_registration_id and optional version. + if version is provided, it fetches that specific version, otherwise it fetches the latest version. + """ + filters = { + "eap_registration__id": eap_registration_id, + } + if version: + filters["version"] = version + + return model.objects.filter(**filters).order_by("-version").first() + 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.get("version", None) + version = validated_data.pop("version", None) if export_type == Export.ExportType.DREF: title = Dref.objects.filter(id=export_id).first().title elif export_type == Export.ExportType.OPS_UPDATE: @@ -2577,20 +2590,20 @@ def create(self, validated_data): 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__id=export_id, version=version).order_by("-version").first() - ) - else: - simplified_eap = SimplifiedEAP.objects.filter(eap_registration__id=export_id).order_by("-version").first() + simplified_eap = self.get_latest( + model=SimplifiedEAP, + eap_registration_id=export_id, + version=version, + ) 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__id=export_id, version=version).order_by("-version").first() - else: - full_eap = FullEAP.objects.filter(eap_registration__id=export_id).order_by("-version").first() + full_eap = self.get_latest( + model=FullEAP, + eap_registration_id=export_id, + version=version, + ) title = f"{full_eap.eap_registration.national_society.name}-{full_eap.eap_registration.disaster_type.name}" else: title = "Export" @@ -2599,17 +2612,17 @@ def create(self, validated_data): if export_type == Export.ExportType.PER: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/countries/{country_id}/{export_type}/{export_id}/export/" - if export_type in [ + elif export_type in [ Export.ExportType.SIMPLIFIED_EAP, Export.ExportType.FULL_EAP, ]: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/eap/{export_id}/{export_type}/export/" # NOTE: EAP exports with diff view only for EAPs exports + if version: + validated_data["url"] += f"?version={version}" diff = validated_data.pop("diff") if diff: - validated_data["url"] += "?diff=true" - if version: - validated_data["url"] += f"&version={version}" if diff else f"?version={version}" + validated_data["url"] += "&diff=true" if version else "?diff=true" else: validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/{export_type}/{export_id}/export/" diff --git a/api/tasks.py b/api/tasks.py index 11bcf357f..c551d7507 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -122,8 +122,6 @@ def generate_url(url, export_id, user, title, language): file_name = f'SIMPLIFIED EAP {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' elif export.export_type == Export.ExportType.FULL_EAP: file_name = f'FULL EAP {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' - elif export.export_type == Export.ExportType.EAP_REGISTRATION: - file_name = f'EAP REGISTRATION {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( diff --git a/eap/test_views.py b/eap/test_views.py index 85b345b94..e93420b83 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1666,10 +1666,41 @@ def test_simplified_eap_export(self, mock_generate_url): django_get_language(), ) + # Test Export Snapshot + + # create a new snapshot + simplfied_eap_snapshot = self.simplified_eap.generate_snapshot() + assert simplfied_eap_snapshot.version == 2, "Snapshot version should be 2" + + data = { + "export_type": Export.ExportType.SIMPLIFIED_EAP, + "export_id": self.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/{self.eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/?version=2" + ) + self.assertEqual(response.data["url"], expected_url) + @mock.patch("api.serializers.generate_url.delay") def test_full_eap_export(self, mock_generate_url): - self.full_eap = FullEAPFactory.create( - eap_registration=self.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.user, + modified_by=self.user, + ) + + FullEAPFactory.create( + eap_registration=eap_registration, created_by=self.user, modified_by=self.user, budget_file=EAPFileFactory._create_file( @@ -1679,7 +1710,7 @@ def test_full_eap_export(self, mock_generate_url): ) data = { "export_type": Export.ExportType.FULL_EAP, - "export_id": self.eap_registration.id, + "export_id": eap_registration.id, "is_pga": False, } @@ -1689,7 +1720,7 @@ def test_full_eap_export(self, mock_generate_url): 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/{self.eap_registration.id}/{Export.ExportType.FULL_EAP}/export/" + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/{Export.ExportType.FULL_EAP}/export/" self.assertEqual(response.data["url"], expected_url) self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) @@ -1703,6 +1734,56 @@ def test_full_eap_export(self, mock_generate_url): django_get_language(), ) + @mock.patch("api.serializers.generate_url.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, + ) + + 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, + ), + ) + + self.authenticate(self.user) + data = { + "export_type": Export.ExportType.SIMPLIFIED_EAP, + "export_id": eap_registration.id, + "diff": True, + } + + 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.ExportType.SIMPLIFIED_EAP}/export/?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( + expected_url, + response.data["id"], + self.user.id, + title, + django_get_language(), + ) + class EAPFullTestCase(APITestCase): def setUp(self): From 8050054b7372a04363beed881ad359f27c362657 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 14:15:51 +0545 Subject: [PATCH 37/57] feat(eap): Update on Export url for eaps - Add default ordering - Add created and modified at --- api/serializers.py | 57 +++++++++++++++++++++++++++------------------- eap/serializers.py | 6 +++++ eap/test_views.py | 38 +++++++++++++++++++------------ eap/views.py | 1 - 4 files changed, 62 insertions(+), 40 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 594159195..08a4cc5ef 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -15,7 +15,7 @@ from api.utils import CountryValidator, RegionValidator from deployments.models import EmergencyProject, Personnel, PersonnelDeployment from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate -from eap.models import FullEAP, SimplifiedEAP +from eap.models import EAPRegistration, FullEAP, SimplifiedEAP from lang.models import String from lang.serializers import ModelSerializer from local_units.models import DelegationOffice @@ -2561,19 +2561,6 @@ def validate_pdf_file(self, pdf_file): validate_file_type(pdf_file) return pdf_file - def get_latest(self, model: type[SimplifiedEAP | FullEAP], eap_registration_id: int, version: int | None = None): - """ - Get the latest version of the EAP (Simplified or Full) based on the eap_registration_id and optional version. - if version is provided, it fetches that specific version, otherwise it fetches the latest version. - """ - filters = { - "eap_registration__id": eap_registration_id, - } - if version: - filters["version"] = version - - return model.objects.filter(**filters).order_by("-version").first() - def create(self, validated_data): language = django_get_language() export_id = validated_data.get("export_id") @@ -2590,20 +2577,42 @@ def create(self, validated_data): 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: - simplified_eap = self.get_latest( - model=SimplifiedEAP, - eap_registration_id=export_id, - version=version, - ) + 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: - full_eap = self.get_latest( - model=FullEAP, - eap_registration_id=export_id, - version=version, - ) + 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" diff --git a/eap/serializers.py b/eap/serializers.py index bf863204f..f18672f04 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -98,6 +98,8 @@ class Meta: "version", "is_locked", "updated_checklist_file", + "created_at", + "modified_at", ] @@ -118,6 +120,8 @@ class Meta: "version", "is_locked", "updated_checklist_file", + "created_at", + "modified_at", ] @@ -143,6 +147,8 @@ class Meta: "requirement_cost", "activated_at", "approved_at", + "created_at", + "modified_at", ] diff --git a/eap/test_views.py b/eap/test_views.py index e93420b83..5abedca49 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1614,8 +1614,11 @@ def setUp(self): self.partner2 = CountryFactory.create(name="partner2", iso3="AAA", iso="AA") self.user = UserFactory.create() + self.url = "/api/v2/pdf-export/" - self.eap_registration = EAPRegistrationFactory.create( + @mock.patch("api.serializers.generate_url.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, @@ -1624,13 +1627,8 @@ def setUp(self): created_by=self.user, modified_by=self.user, ) - - self.url = "/api/v2/pdf-export/" - - @mock.patch("api.serializers.generate_url.delay") - def test_simplified_eap_export(self, mock_generate_url): - self.simplified_eap = SimplifiedEAPFactory.create( - eap_registration=self.eap_registration, + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=eap_registration, created_by=self.user, modified_by=self.user, national_society_contact_title="NS Title Example", @@ -1639,9 +1637,12 @@ def test_simplified_eap_export(self, mock_generate_url): modified_by=self.user, ), ) + eap_registration.latest_simplified_eap = simplified_eap + eap_registration.save() + data = { "export_type": Export.ExportType.SIMPLIFIED_EAP, - "export_id": self.eap_registration.id, + "export_id": eap_registration.id, "is_pga": False, } @@ -1652,7 +1653,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{self.eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/" + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/" self.assertEqual(response.data["url"], expected_url) self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) @@ -1669,12 +1670,12 @@ def test_simplified_eap_export(self, mock_generate_url): # Test Export Snapshot # create a new snapshot - simplfied_eap_snapshot = self.simplified_eap.generate_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": self.eap_registration.id, + "export_id": eap_registration.id, "version": 2, } @@ -1684,7 +1685,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assertIsNotNone(response.data["id"], response.data) expected_url = ( - f"{settings.GO_WEB_INTERNAL_URL}/eap/{self.eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/?version=2" + f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/?version=2" ) self.assertEqual(response.data["url"], expected_url) @@ -1699,7 +1700,7 @@ def test_full_eap_export(self, mock_generate_url): modified_by=self.user, ) - FullEAPFactory.create( + full_eap = FullEAPFactory.create( eap_registration=eap_registration, created_by=self.user, modified_by=self.user, @@ -1708,6 +1709,10 @@ def test_full_eap_export(self, mock_generate_url): 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, @@ -1745,7 +1750,7 @@ def test_diff_export_eap(self, mock_generate_url): modified_by=self.user, ) - SimplifiedEAPFactory.create( + simplified_eap = SimplifiedEAPFactory.create( eap_registration=eap_registration, created_by=self.user, modified_by=self.user, @@ -1755,6 +1760,9 @@ def test_diff_export_eap(self, mock_generate_url): ), ) + eap_registration.latest_simplified_eap = simplified_eap + eap_registration.save() + self.authenticate(self.user) data = { "export_type": Export.ExportType.SIMPLIFIED_EAP, diff --git a/eap/views.py b/eap/views.py index c3fccf92e..6ca759a11 100644 --- a/eap/views.py +++ b/eap/views.py @@ -104,7 +104,6 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: "partners", "simplified_eap", ) - .order_by("id") ) @action( From f892aaf91dc0c55fe62af12aa27ed34f5ccdd492 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 12:43:24 +0545 Subject: [PATCH 38/57] fix(eap): typing issue on eap actiona and source information --- eap/serializers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/eap/serializers.py b/eap/serializers.py index 767b3af43..bf863204f 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -352,7 +352,7 @@ class Meta: ) -class SourceInformationSerializer( +class EAPSourceInformationSerializer( serializers.ModelSerializer, ): id = serializers.IntegerField(required=False) @@ -373,7 +373,7 @@ class Meta: fields = "__all__" -class ActionSerializer(serializers.ModelSerializer): +class EAPActionSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) class Meta: @@ -560,15 +560,15 @@ class FullEAPSerializer( # admins key_actors = KeyActorSerializer(many=True, required=True) - early_actions = ActionSerializer(many=True, required=False) + early_actions = EAPActionSerializer(many=True, required=False) prioritized_impacts = ImpactSerializer(many=True, required=False) # SOURCE OF INFORMATIONS - risk_analysis_source_of_information = SourceInformationSerializer(many=True, required=False) - trigger_statement_source_of_information = SourceInformationSerializer(many=True, required=False) - trigger_model_source_of_information = SourceInformationSerializer(many=True, required=False) - evidence_base_source_of_information = SourceInformationSerializer(many=True, required=False) - activation_process_source_of_information = SourceInformationSerializer(many=True, required=False) + risk_analysis_source_of_information = EAPSourceInformationSerializer(many=True, required=False) + trigger_statement_source_of_information = EAPSourceInformationSerializer(many=True, required=False) + trigger_model_source_of_information = EAPSourceInformationSerializer(many=True, required=False) + evidence_base_source_of_information = EAPSourceInformationSerializer(many=True, required=False) + activation_process_source_of_information = EAPSourceInformationSerializer(many=True, required=False) # IMAGES hazard_selection_images = EAPFileUpdateSerializer( From d965a2604e421c0bd5ce49bb4ce2fcf35943d09b Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 15 Dec 2025 13:23:06 +0545 Subject: [PATCH 39/57] fix(eap): Replace update checklist file to EAPFile --- ...fulleap_updated_checklist_file_and_more.py | 35 ++++++++++ eap/models.py | 7 +- eap/serializers.py | 4 +- eap/test_views.py | 65 ++++++++++--------- 4 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py diff --git a/eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py b/eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py new file mode 100644 index 000000000..1bc9310dd --- /dev/null +++ b/eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.26 on 2025-12-15 06:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0010_eapaction_eapimpact_indicator_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="fulleap", + name="updated_checklist_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="eap.eapfile", + verbose_name="Updated Review Checklist File", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="updated_checklist_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="eap.eapfile", + verbose_name="Updated Review Checklist File", + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index f765a5e18..a4ca0454f 100644 --- a/eap/models.py +++ b/eap/models.py @@ -879,9 +879,10 @@ class CommonEAPFields(models.Model): ) # Review Checklist - updated_checklist_file = SecureFileField( - verbose_name=_("Updated Checklist File"), - upload_to="eap/files/", + 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, ) diff --git a/eap/serializers.py b/eap/serializers.py index f18672f04..2ee1889ca 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -427,8 +427,8 @@ def validate_updated_checklist_file(self, file): if file is None: return - validate_file_extention(file.name, ALLOWED_FILE_EXTENTIONS) - validate_file_type(file) + validate_file_extention(file.file.name, ALLOWED_FILE_EXTENTIONS) + validate_file_type(file.file) return file def validate_images_field(self, field_name, images): diff --git a/eap/test_views.py b/eap/test_views.py index 5abedca49..47fe0840f 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1245,7 +1245,7 @@ def test_status_transition(self): ) # Snapshot Shouldn't have the updated checklist file self.assertFalse( - second_snapshot.updated_checklist_file.name, + second_snapshot.updated_checklist_file, "Latest Snapshot shouldn't have the updated checklist file.", ) # Check if the latest_simplified_eap is updated in EAPRegistration @@ -1268,14 +1268,18 @@ def test_status_transition(self): # Upload updated checklist file # UPDATES on the second snapshot url = f"/api/v2/simplified-eap/{second_snapshot.id}/" - with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: - tmp_file.write(b"Updated Test content") - tmp_file.seek(0) - - file_data = {"eap_registration": second_snapshot.eap_registration_id, "updated_checklist_file": tmp_file} + 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="multipart") - self.assertEqual(response.status_code, 200, response.data) + response = self.client.patch(url, file_data, format="json") + self.assertEqual(response.status_code, 200, response.data) # SUCCESS: self.authenticate(self.country_admin) @@ -1336,7 +1340,7 @@ def test_status_transition(self): self.assertTrue(second_snapshot.is_locked) # Snapshot Shouldn't have the updated checklist file self.assertFalse( - third_snapshot.updated_checklist_file.name, + third_snapshot.updated_checklist_file, "Latest snapshot shouldn't have the updated checklist file.", ) @@ -1356,14 +1360,17 @@ def test_status_transition(self): # Upload updated checklist file # UPDATES on the second snapshot url = f"/api/v2/simplified-eap/{third_snapshot.id}/" - with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: - tmp_file.write(b"Updated Test content") - tmp_file.seek(0) - - file_data = {"eap_registration": third_snapshot.eap_registration_id, "updated_checklist_file": tmp_file} + 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="multipart") - self.assertEqual(response.status_code, 200) + response = self.client.patch(url, file_data, format="json") + self.assertEqual(response.status_code, 200) # SUCCESS: self.authenticate(self.country_admin) @@ -1444,7 +1451,7 @@ def test_status_transition(self): self.assertTrue(third_snapshot.is_locked) # Snapshot Shouldn't have the updated checklist file self.assertFalse( - fourth_snapshot.updated_checklist_file.name, + fourth_snapshot.updated_checklist_file, "Latest snapshot shouldn't have the updated checklist file.", ) @@ -1466,21 +1473,17 @@ def test_status_transition(self): # Upload updated checklist file # UPDATES on the second snapshot url = f"/api/v2/simplified-eap/{fourth_snapshot.id}/" - with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: - tmp_file.write(b"Updated Test content") - tmp_file.seek(0) + 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, + } - 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": tmp_file, - } - - response = self.client.patch(url, file_data, format="multipart") - self.assertEqual(response.status_code, 200) + response = self.client.patch(url, file_data, format="json") + self.assertEqual(response.status_code, 200) # SUCCESS: self.authenticate(self.country_admin) From a5726961ba3759124e2baeaa7dc56babbdff4de6 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 15 Dec 2025 14:05:34 +0545 Subject: [PATCH 40/57] fix(eap): Update export url on eap --- api/serializers.py | 2 +- eap/test_views.py | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 08a4cc5ef..2d470a182 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2625,7 +2625,7 @@ def create(self, validated_data): Export.ExportType.SIMPLIFIED_EAP, Export.ExportType.FULL_EAP, ]: - validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/eap/{export_id}/{export_type}/export/" + validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/eap/{export_id}/export/" # NOTE: EAP exports with diff view only for EAPs exports if version: validated_data["url"] += f"?version={version}" diff --git a/eap/test_views.py b/eap/test_views.py index 47fe0840f..ab9994938 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1656,7 +1656,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/" + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/" self.assertEqual(response.data["url"], expected_url) self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) @@ -1687,9 +1687,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = ( - f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/?version=2" - ) + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/?version=2" self.assertEqual(response.data["url"], expected_url) @mock.patch("api.serializers.generate_url.delay") @@ -1728,7 +1726,7 @@ def test_full_eap_export(self, mock_generate_url): 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.ExportType.FULL_EAP}/export/" + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/" self.assertEqual(response.data["url"], expected_url) self.assertEqual(response.data["status"], Export.ExportStatus.PENDING) @@ -1780,9 +1778,7 @@ def test_diff_export_eap(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = ( - f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/{Export.ExportType.SIMPLIFIED_EAP}/export/?diff=true" - ) + expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/?diff=true" self.assertEqual(response.data["url"], expected_url) self.assertEqual(mock_generate_url.called, True) From 57b22c795625dd0f2b67517d71b30f684b520f6a Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 19 Dec 2025 12:06:45 +0545 Subject: [PATCH 41/57] chore(fulleap): Remove fields from fulleap model (#2614) --- eap/factories.py | 28 +++- ..._remove_fulleap_seap_timeframe_and_more.py | 140 ++++++++++++++++++ eap/models.py | 37 ++--- eap/serializers.py | 15 +- eap/test_views.py | 16 +- 5 files changed, 201 insertions(+), 35 deletions(-) create mode 100644 eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py diff --git a/eap/factories.py b/eap/factories.py index 90f3dd1f3..4459fb6bd 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -49,6 +49,8 @@ class Meta: 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): @@ -73,6 +75,14 @@ class Meta: 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 enable_approaches(self, create, extracted, **kwargs): @@ -185,7 +195,6 @@ class FullEAPFactory(factory.django.DjangoModelFactory): class Meta: model = FullEAP - seap_timeframe = fuzzy.FuzzyInteger(5) expected_submission_time = fuzzy.FuzzyDateTime(datetime(2025, 1, 1, tzinfo=pytz.utc)) lead_time = fuzzy.FuzzyInteger(1, 100) total_budget = fuzzy.FuzzyInteger(1000, 1000000) @@ -193,3 +202,20 @@ class Meta: 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/migrations/0012_remove_fulleap_seap_timeframe_and_more.py b/eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py new file mode 100644 index 000000000..57ff54f65 --- /dev/null +++ b/eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py @@ -0,0 +1,140 @@ +# Generated by Django 4.2.26 on 2025-12-19 04:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0011_alter_fulleap_updated_checklist_file_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="fulleap", + name="seap_timeframe", + ), + migrations.RemoveField( + model_name="fulleap", + name="selection_area", + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_delegation_focal_point_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="IFRC delegation focal point email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_delegation_focal_point_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="IFRC delegation focal point name", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_head_of_delegation_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="IFRC head of delegation email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_head_of_delegation_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="IFRC head of delegation name", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="national_society_contact_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="national society contact email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="national_society_contact_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="national society contact name", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="IFRC delegation focal point email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="IFRC delegation focal point name", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="IFRC head of delegation email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="IFRC head of delegation name", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="national_society_contact_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="national society contact email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="national_society_contact_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="national society contact name", + ), + preserve_default=False, + ), + ] diff --git a/eap/models.py b/eap/models.py index a4ca0454f..403e260da 100644 --- a/eap/models.py +++ b/eap/models.py @@ -749,11 +749,6 @@ class CommonEAPFields(models.Model): related_name="+", ) - seap_timeframe = models.IntegerField( - verbose_name=_("Timeframe (Years) of the EAP"), - help_text=_("Timeframe of the EAP in years."), - ) - admin2 = models.ManyToManyField( Admin2, verbose_name=_("admin"), @@ -768,13 +763,15 @@ class CommonEAPFields(models.Model): # Contacts # National Society national_society_contact_name = models.CharField( - verbose_name=_("national society contact name"), max_length=255, null=True, blank=True + 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, null=True, blank=True + 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 @@ -787,12 +784,8 @@ class CommonEAPFields(models.Model): partner_ns_phone_number = models.CharField(verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True) # Delegations - ifrc_delegation_focal_point_name = models.CharField( - verbose_name=_("IFRC delegation focal point name"), max_length=255, null=True, blank=True - ) - ifrc_delegation_focal_point_email = models.CharField( - verbose_name=_("IFRC delegation focal point email"), max_length=255, null=True, blank=True - ) + 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 ) @@ -800,12 +793,8 @@ class CommonEAPFields(models.Model): 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, null=True, blank=True - ) - ifrc_head_of_delegation_email = models.CharField( - verbose_name=_("IFRC head of delegation email"), max_length=255, 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 ) @@ -926,6 +915,11 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): related_name="simplified_eap", ) + 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 # @@ -1284,11 +1278,6 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - selection_area = models.TextField( - verbose_name=_("Areas selection rationale"), - help_text=_("Add description for the selection of the areas."), - ) - trigger_model_relevant_files = models.ManyToManyField( EAPFile, blank=True, diff --git a/eap/serializers.py b/eap/serializers.py index 2ee1889ca..01a9b5b89 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -115,7 +115,6 @@ class Meta: "readiness_budget", "pre_positioning_budget", "early_action_budget", - "seap_timeframe", "budget_file", "version", "is_locked", @@ -448,8 +447,8 @@ class SimplifiedEAPSerializer( # FILES hazard_impact_images = EAPFileUpdateSerializer(required=False, many=True) - selected_early_actions_images = EAPFileUpdateSerializer(required=False, many=True) - risk_selected_protocols_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) @@ -570,11 +569,11 @@ class FullEAPSerializer( prioritized_impacts = ImpactSerializer(many=True, required=False) # SOURCE OF INFORMATIONS - risk_analysis_source_of_information = EAPSourceInformationSerializer(many=True, required=False) - trigger_statement_source_of_information = EAPSourceInformationSerializer(many=True, required=False) - trigger_model_source_of_information = EAPSourceInformationSerializer(many=True, required=False) - evidence_base_source_of_information = EAPSourceInformationSerializer(many=True, required=False) - activation_process_source_of_information = EAPSourceInformationSerializer(many=True, required=False) + 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( diff --git a/eap/test_views.py b/eap/test_views.py index ab9994938..639cc6dbf 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -131,6 +131,8 @@ def test_create_eap_registration(self): "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) @@ -464,6 +466,12 @@ def test_create_simplified_eap(self): 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", "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "risks_selected_protocols": "Protocol A and Protocol B.", "selected_early_actions": "The early actions selected.", @@ -1875,6 +1883,12 @@ def test_create_full_eap(self): 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", "budget_file": budget_file_instance.id, "forecast_table_file": forecast_table_file.id, "hazard_selection_images": [ @@ -1918,7 +1932,6 @@ def test_create_full_eap(self): "objective": "FUll eap objective", "lead_time": 5, "expected_submission_time": "2024-12-31", - "seap_timeframe": 5, "readiness_budget": 3000, "pre_positioning_budget": 4000, "early_action_budget": 3000, @@ -1945,7 +1958,6 @@ def test_create_full_eap(self): "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", - "selection_area": "Selection of the area", "early_action_selection_process": "Early action selection process", "evidence_base": "Evidence base", "usefulness_of_actions": "Usefulness of actions", From 0d07d4eed3e6d8b9b3ce78097f1ce1ace3423117 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 19 Dec 2025 13:43:56 +0545 Subject: [PATCH 42/57] chore(eap-registration): Update fields on eap registration --- ...national_society_contact_email_and_more.py | 32 +++++++++++++++++++ eap/models.py | 6 ++-- eap/test_views.py | 4 +-- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py diff --git a/eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py b/eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py new file mode 100644 index 000000000..9681cf2ca --- /dev/null +++ b/eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.26 on 2025-12-19 07:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0012_remove_fulleap_seap_timeframe_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="eapregistration", + name="national_society_contact_email", + field=models.CharField( + default="test@gmail.com", + max_length=255, + verbose_name="national society contact email", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="eapregistration", + name="national_society_contact_name", + field=models.CharField( + default="test", + max_length=255, + verbose_name="national society contact name", + ), + preserve_default=False, + ), + ] diff --git a/eap/models.py b/eap/models.py index 403e260da..99445c636 100644 --- a/eap/models.py +++ b/eap/models.py @@ -638,13 +638,15 @@ class EAPRegistration(EAPBaseModel): # Contacts # National Society national_society_contact_name = models.CharField( - verbose_name=_("national society contact name"), max_length=255, null=True, blank=True + 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, null=True, blank=True + 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 diff --git a/eap/test_views.py b/eap/test_views.py index 639cc6dbf..97b1d0661 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -202,8 +202,8 @@ def test_update_eap_registration(self): # Authenticate as root user self.authenticate(self.root_user) - response = self.client.put(url, data, format="json") - self.assertEqual(response.status_code, 200) + 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"]) From 17fe2e55d5ccc77ed9baac3daba850aa8df79147 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 5 Jan 2026 23:19:39 +0545 Subject: [PATCH 43/57] feat(eap): Add diff file and summary file for eap - refactor pdf generation - Fix failing appeal test cases - Update test cases - Add additional valiations on eaps --- api/playwright.py | 121 ++++++++++++++ api/serializers.py | 31 ++-- api/tasks.py | 144 ++++------------- api/test_views.py | 144 +++++++++-------- api/utils.py | 24 +++ eap/admin.py | 62 ++++++- eap/factories.py | 11 ++ eap/migrations/0014_eapcontact_and_more.py | 180 +++++++++++++++++++++ eap/models.py | 57 ++++++- eap/permissions.py | 43 ++++- eap/serializers.py | 77 +++++++-- eap/tasks.py | 118 ++++++++++++++ eap/test_views.py | 75 +++++++-- 13 files changed, 856 insertions(+), 231 deletions(-) create mode 100644 api/playwright.py create mode 100644 eap/migrations/0014_eapcontact_and_more.py create mode 100644 eap/tasks.py 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 2d470a182..27e571860 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -11,8 +11,8 @@ 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 @@ -2548,9 +2548,11 @@ class ExportSerializer(serializers.ModelSerializer): 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) + 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) + 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 @@ -2562,11 +2564,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: @@ -2625,19 +2628,18 @@ def create(self, validated_data): Export.ExportType.SIMPLIFIED_EAP, Export.ExportType.FULL_EAP, ]: - validated_data["url"] = f"{settings.GO_WEB_INTERNAL_URL}/eap/{export_id}/export/" - # NOTE: EAP exports with diff view only for EAPs exports - if version: - validated_data["url"] += f"?version={version}" - diff = validated_data.pop("diff") - if diff: - validated_data["url"] += "&diff=true" if version else "?diff=true" + 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 @@ -2647,7 +2649,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 c551d7507..15752c2ca 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -12,6 +12,7 @@ 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 @@ -19,130 +20,45 @@ 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' - elif export.export_type == Export.ExportType.SIMPLIFIED_EAP: - file_name = f'SIMPLIFIED EAP {title} ({datetime.now().strftime("%Y-%m-%d %H-%M-%S")}).pdf' - elif export.export_type == Export.ExportType.FULL_EAP: - file_name = f'FULL EAP {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 2b40dff5e..263f45534 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -1,5 +1,7 @@ import re import uuid +import datetime +from django.utils import timezone from unittest.mock import patch from django.contrib.auth.models import User @@ -868,73 +870,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"], 2) + 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..4b89da6f1 100644 --- a/api/utils.py +++ b/api/utils.py @@ -156,3 +156,27 @@ class RegionValidator(TypedDict): class CountryValidator(TypedDict): country: int local_unit_types: list[int] + + +def generate_eap_export_url( + registration_id: int, + diff: bool = False, + version: Optional[int] = None, + summary: bool = False, +) -> str: + """ + Generate EAP export URL for given registration ID, version and diff flag. + """ + from django.conf import settings + + url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{registration_id}/export/" + 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" + + if summary: + url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{registration_id}/summary/export/" + return url diff --git a/eap/admin.py b/eap/admin.py index af6d0b1e0..2ba116277 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin +from django.db import transaction -from eap.models import EAPFile, EAPRegistration, FullEAP, KeyActor, SimplifiedEAP +from eap.models import EAPFile, EAPRegistration, EAPType, FullEAP, KeyActor, SimplifiedEAP @admin.register(EAPFile) @@ -16,6 +17,7 @@ class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): "country__name", "disaster_type__name", ) + readonly_fields = ("summary_file",) list_filter = ("eap_type",) list_display = ( "national_society_name", @@ -30,6 +32,22 @@ class DevelopmentRegistrationEAPAdmin(admin.ModelAdmin): "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 @@ -69,6 +87,7 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): ) readonly_fields = ( "cover_image", + "partner_contacts", "hazard_impact_images", "risk_selected_protocols_images", "selected_early_actions_images", @@ -78,6 +97,25 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): "is_locked", "version", ) + actions = [ + "regenerate_diff_pdf_file", + ] + + 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}" @@ -97,6 +135,7 @@ def get_queryset(self, request): ) .prefetch_related( "admin2", + "partner_contacts", ) ) @@ -121,6 +160,7 @@ class FullEAPAdmin(admin.ModelAdmin): "admin2", ) readonly_fields = ( + "partner_contacts", "cover_image", "planned_operations", "enable_approaches", @@ -143,6 +183,25 @@ class FullEAPAdmin(admin.ModelAdmin): "capacity_relevant_files", "forecast_table_file", ) + actions = [ + "regenerate_diff_pdf_file", + ] + + 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 ( @@ -158,6 +217,7 @@ def get_queryset(self, request): ) .prefetch_related( "admin2", + "partner_contacts", "key_actors", "risk_analysis_source_of_information", "trigger_statement_source_of_information", diff --git a/eap/factories.py b/eap/factories.py index 4459fb6bd..219d705ee 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -5,6 +5,7 @@ from factory import fuzzy from eap.models import ( + EAPContact, EAPFile, EAPRegistration, EAPStatus, @@ -43,6 +44,16 @@ def _create_file(cls, *args, **kwargs) -> EAPFile: ) +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 diff --git a/eap/migrations/0014_eapcontact_and_more.py b/eap/migrations/0014_eapcontact_and_more.py new file mode 100644 index 000000000..8a5433af5 --- /dev/null +++ b/eap/migrations/0014_eapcontact_and_more.py @@ -0,0 +1,180 @@ +# Generated by Django 4.2.26 on 2026-01-05 16:16 + +from django.db import migrations, models +import main.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0013_alter_eapregistration_national_society_contact_email_and_more"), + ] + + operations = [ + 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", + ), + ), + ], + options={ + "verbose_name": "EAP Contact", + "verbose_name_plural": "EAP Contacts", + }, + ), + migrations.RemoveField( + model_name="eapregistration", + name="review_checklist_file", + ), + migrations.RemoveField( + model_name="fulleap", + name="partner_ns_email", + ), + migrations.RemoveField( + model_name="fulleap", + name="partner_ns_name", + ), + migrations.RemoveField( + model_name="fulleap", + name="partner_ns_phone_number", + ), + migrations.RemoveField( + model_name="fulleap", + name="partner_ns_title", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="partner_ns_email", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="partner_ns_name", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="partner_ns_phone_number", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="partner_ns_title", + ), + migrations.AddField( + model_name="eapregistration", + name="summary_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="EAP Summary PDF", + ), + ), + migrations.AddField( + model_name="fulleap", + name="diff_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="EAP Diff PDF file", + ), + ), + migrations.AddField( + model_name="fulleap", + name="export_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/exports/", + verbose_name="EAP Export File", + ), + ), + migrations.AddField( + model_name="fulleap", + name="review_checklist_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="Review Checklist File", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="diff_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="EAP Diff PDF file", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="export_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/exports/", + verbose_name="EAP Export File", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="review_checklist_file", + field=main.fields.SecureFileField( + blank=True, + null=True, + upload_to="eap/files/", + verbose_name="Review Checklist File", + ), + ), + migrations.AddField( + model_name="fulleap", + name="partner_contacts", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapcontact", + verbose_name="Partner NS Contacts", + ), + ), + migrations.AddField( + model_name="simplifiedeap", + name="partner_contacts", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapcontact", + verbose_name="Partner NS Contacts", + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index 99445c636..a52e0cccd 100644 --- a/eap/models.py +++ b/eap/models.py @@ -232,6 +232,20 @@ class Meta: 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) + + 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") @@ -609,9 +623,9 @@ class EAPRegistration(EAPBaseModel): help_text=_("Upload the validated budget file once the EAP is technically validated."), ) - # Review checklist - review_checklist_file = SecureFileField( - verbose_name=_("Review Checklist File"), + # NOTE: Only Full EAP have summary PDF + summary_file = SecureFileField( + verbose_name=_("EAP Summary PDF"), upload_to="eap/files/", null=True, blank=True, @@ -780,10 +794,12 @@ class CommonEAPFields(models.Model): ) # Partners NS - partner_ns_name = models.CharField(verbose_name=_("Partner NS name"), max_length=255, null=True, blank=True) - partner_ns_email = models.CharField(verbose_name=_("Partner NS email"), max_length=255, null=True, blank=True) - partner_ns_title = models.CharField(verbose_name=_("Partner NS title"), max_length=255, null=True, blank=True) - partner_ns_phone_number = models.CharField(verbose_name=_("Partner NS phone number"), max_length=100, null=True, blank=True) + 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) @@ -869,7 +885,30 @@ class CommonEAPFields(models.Model): 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, @@ -1095,7 +1134,9 @@ def generate_snapshot(self): "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, }, exclude_clone_m2m_fields={ "admin2", @@ -1518,7 +1559,9 @@ def generate_snapshot(self): "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, }, exclude_clone_m2m_fields={ "admin2", diff --git a/eap/permissions.py b/eap/permissions.py index dc75b1e9c..2b7b7ecfc 100644 --- a/eap/permissions.py +++ b/eap/permissions.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import Permission from rest_framework.permissions import BasePermission +from api.models import Country from eap.models import EAPRegistration @@ -18,10 +19,28 @@ def has_country_permission( codename__startswith="country_admin_", ).values_list("codename", flat=True) ] - # TODO(susilnem): Add region admin check if needed in future + 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" @@ -31,7 +50,18 @@ def has_permission(self, request, view) -> bool: user = request.user national_society_id = request.data.get("national_society") - return user.is_superuser or has_country_permission(user=user, national_society_id=national_society_id) + 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): @@ -48,7 +78,14 @@ def has_object_permission(self, request, view, obj) -> bool: 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) + 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): diff --git a/eap/serializers.py b/eap/serializers.py index 7ed2d01e6..bd6a00719 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,5 +1,6 @@ import typing +from django.db import transaction from django.contrib.auth.models import User from django.utils import timezone from django.utils.translation import gettext @@ -15,6 +16,7 @@ from eap.models import ( DaysTimeFrameChoices, EAPAction, + EAPContact, EAPFile, EAPImpact, EAPRegistration, @@ -32,6 +34,7 @@ TimeFrame, YearsTimeFrameChoices, ) +from eap.tasks import generate_eap_summary_pdf, generate_export_diff_pdf from eap.utils import ( has_country_permission, is_user_ifrc_admin, @@ -176,7 +179,6 @@ class Meta: read_only_fields = [ "status", "validated_budget_file", - "review_checklist_file", "modified_at", "created_by", "modified_by", @@ -398,9 +400,28 @@ class Meta: fields = "__all__" + + +class EAPContactSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = EAPContact + fields = ( + "id", + "title", + "name", + "email", + "phone_number", + ) + + class CommonEAPFieldsSerializer(serializers.ModelSerializer): MAX_NUMBER_OF_IMAGES = 5 + # Partner NS Contact + partner_contacts = EAPContactSerializer(many=True, required=False) + planned_operations = PlannedOperationSerializer(many=True, required=False) enable_approaches = EnableApproachSerializer(many=True, required=False) @@ -411,6 +432,7 @@ class CommonEAPFieldsSerializer(serializers.ModelSerializer): 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) @@ -534,6 +556,15 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: 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.") @@ -678,6 +709,15 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: 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.") @@ -739,6 +779,7 @@ class Meta: 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.")) @@ -766,7 +807,8 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t gettext("You do not have permission to change status to %s.") % EAPRegistration.Status(new_status).label ) - if not validated_data.get("review_checklist_file"): + 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 @@ -776,6 +818,8 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t 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() @@ -815,6 +859,13 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t gettext("NS Addressing Comments file must be uploaded before changing status to %s.") % EAPRegistration.Status(new_status).label ) + + transaction.on_commit( + lambda: generate_export_diff_pdf.delay( + eap_registration_id=self.instance.id, + version=self.instance.latest_simplified_eap.version, + ) + ) else: if not (self.instance.latest_full_eap and self.instance.latest_full_eap.updated_checklist_file): raise serializers.ValidationError( @@ -822,19 +873,11 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) - elif (current_status, new_status) == ( - 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 - ) - - if not validated_data.get("review_checklist_file"): - raise serializers.ValidationError( - gettext("Review checklist file must be uploaded before changing status to %s.") - % EAPRegistration.Status(new_status).label + transaction.on_commit( + lambda: generate_export_diff_pdf.delay( + eap_registration_id=self.instance.id, + version=self.instance.latest_full_eap.version, + ) ) elif (current_status, new_status) == ( @@ -872,6 +915,10 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ] ) + # 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.APPROVED, EAPRegistration.Status.ACTIVATED, diff --git a/eap/tasks.py b/eap/tasks.py new file mode 100644 index 000000000..c01d158b6 --- /dev/null +++ b/eap/tasks.py @@ -0,0 +1,118 @@ +from datetime import datetime + +from celery import shared_task +from django.contrib.auth.models import User +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 main.utils import logger_context + + +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 Summary {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) + eap_registration.save( + update_fields=[ + "summary_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) + simplified_eap.save( + update_fields=[ + "diff_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) + full_eap.save( + update_fields=[ + "diff_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), + ), + ) diff --git a/eap/test_views.py b/eap/test_views.py index 6844b5272..0115a95bd 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -443,6 +443,7 @@ 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, @@ -472,6 +473,18 @@ def test_create_simplified_eap(self): "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.", @@ -631,6 +644,7 @@ def test_create_simplified_eap(self): 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, @@ -1186,6 +1200,19 @@ def test_status_transition(self): 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 = { @@ -1216,7 +1243,7 @@ def test_status_transition(self): self.eap_registration.refresh_from_db() self.assertIsNotNone( - self.eap_registration.review_checklist_file, + self.eap_registration.latest_simplified_eap.review_checklist_file, ) # NOTE: Check if snapshot is created or not @@ -1560,6 +1587,12 @@ def test_status_transition(self): 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 = { @@ -1583,6 +1616,13 @@ def test_status_transition(self): 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 = { @@ -1606,6 +1646,13 @@ def test_status_transition(self): 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) + class EAPPDFExportTestCase(APITestCase): def setUp(self): @@ -1627,7 +1674,7 @@ def setUp(self): self.user = UserFactory.create() self.url = "/api/v2/pdf-export/" - @mock.patch("api.serializers.generate_url.delay") + @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, @@ -1671,9 +1718,7 @@ def test_simplified_eap_export(self, mock_generate_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( - expected_url, response.data["id"], - self.user.id, title, django_get_language(), ) @@ -1698,7 +1743,7 @@ def test_simplified_eap_export(self, mock_generate_url): expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/?version=2" self.assertEqual(response.data["url"], expected_url) - @mock.patch("api.serializers.generate_url.delay") + @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, @@ -1741,14 +1786,12 @@ def test_full_eap_export(self, mock_generate_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( - expected_url, response.data["id"], - self.user.id, title, django_get_language(), ) - @mock.patch("api.serializers.generate_url.delay") + @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, @@ -1792,9 +1835,7 @@ def test_diff_export_eap(self, mock_generate_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( - expected_url, response.data["id"], - self.user.id, title, django_get_language(), ) @@ -1889,6 +1930,19 @@ def test_create_full_eap(self): "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", + "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": [ @@ -2100,6 +2154,7 @@ def test_update_full_eap(self): 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, From e7491af8fe0718396dd633e73b8710bcf9a1a102 Mon Sep 17 00:00:00 2001 From: Sudip Khanal <101724348+sudip-khanal@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:56:42 +0545 Subject: [PATCH 44/57] EAP: Add api to download template files (#2619) --- eap/serializers.py | 6 +- eap/test_views.py | 37 ++++++++++++ eap/views.py | 55 +++++++++++++++++- go-static/files/eap/budget_template.xlsm | Bin 0 -> 285349 bytes go-static/files/eap/forecasts_table.docx | Bin 0 -> 35886 bytes .../files/eap/theory_of_change_table.docx | Bin 0 -> 39171 bytes main/urls.py | 1 + 7 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 go-static/files/eap/budget_template.xlsm create mode 100644 go-static/files/eap/forecasts_table.docx create mode 100644 go-static/files/eap/theory_of_change_table.docx diff --git a/eap/serializers.py b/eap/serializers.py index 01a9b5b89..7ed2d01e6 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -40,7 +40,7 @@ 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"] +ALLOWED_FILE_EXTENTIONS: list[str] = ["pdf", "docx", "pptx", "xlsx", "xlsm"] class BaseEAPSerializer(serializers.ModelSerializer): @@ -218,6 +218,10 @@ 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) diff --git a/eap/test_views.py b/eap/test_views.py index 97b1d0661..6844b5272 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -2308,3 +2308,40 @@ def test_snapshot_full_eap(self): orig_actors[0].description, snapshot_actors[0].description, ) + + +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/views.py b/eap/views.py index 6ca759a11..dcb13db58 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,7 +1,8 @@ # Create your views here. from django.db.models import Case, F, IntegerField, When from django.db.models.query import Prefetch, QuerySet -from drf_spectacular.utils import extend_schema +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 @@ -29,6 +30,7 @@ from eap.serializers import ( EAPFileInputSerializer, EAPFileSerializer, + EAPGlobalFilesSerializer, EAPRegistrationSerializer, EAPStatusSerializer, EAPValidatedBudgetFileSerializer, @@ -324,3 +326,54 @@ def multiple_file(self, request): 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 0000000000000000000000000000000000000000..99eacf92be98f1d4278bdbe5b97af046562b1daa GIT binary patch literal 285349 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 Ct~ez>RB2(i_#ly~|$`m%Tp|F{aZ3_Pua=^;%K1 zDM)CNuR4jBTIpH9ntmOZe*9hcKzshPt%Xq8dbqsGp8I_WyX{T$CE$Jq`PiFilj zcjrTjZQx$MGjK@Vwj77q$&L(vqfY^xRLG6bv5&eY`|z_(^?28`o* ztB}9tI64+9+w2`&Uf=BzJYl#xqF9{|_goIRMxhbj375P-oMa>4A%kELdAtoipI13J zAF^<$xUb$cB>69!I?mE@U!xFGxd0yCt}i;>EcbWQ_kHU;OwG7ex18qG7nRTBWYoZw z-5&P~dMg|A81v`H zzdFn|4`kwW>_v=3v0>s`D^D_d&kLfbv7PT-lx!wnnnH>A8kCvRrR(uHR}}gPn9g(J zQk{@qYeF;b_z?3{$lLnfm&J*M>soPhY}^!kA36$Hyo=J#%A>HQakqEZEn#jwMZm0$1S^D6U=^NLhD!^6?^M<~cu zmR9wd#~KCz%29aG#xz$ES+QDFcd8cKu(TxCHVy1kI4ZiZ%@q5IE0fd&03P;7J781I z`o>)>r-;t(q6KAt_bb~v=4GY*Z>B_oxuPGWYQUZ6W{a=G3M^^sWf%^CHx8}JPdfwU z+X^QyW;d5@K)v~jiJGxj%=|fm;-(~iYeVbXVBaaZ*t3rNLjC&e(j0gIAj2i*^rdeq zi+@N32_esA2Yy#Rs4+4(1XpNiF+alYqboiFGqQI&R{Bf%I;H8`*bD*Z<1(S(GC529 zri{G=bjrqU1-^+R4^0WlO+8ykQ0BSpZ`oLgK7wnqJ`x%7#@gAbd0!ez%!7Aliql;y zj?64=OOr*{*U8h?mGU&cj$mE(p8=;h5v{-QHdh@&GUMjdcITWU<{k&lvcu!UWlsV{ zB}K{ML5NB7gt|maTjS&GrpkTx;w%+{BA6Hz69Y|L0eF|xa&XP`MI%X?7x_?n^+>eU z(;zPj`QeI8&_4qJFj`Uur}BI9f~k}ZHFb>k{h>vqaPFWta-wTlM}u|B%FN!s`ZBt1yY6DdVRYUTGob{F6A<}Eg9)Mx zi4zbLQa}qx5Rd>r!=U64d`$pTMiBtt&h7#4{YJ|VilzHUmMB3S6Lj>PgY{T zKZ%(Pp>cYD7^(*w5dMz%&T5z(VnFt<0nG{mFDYN_7yDh(kdO$S$S{Oga3t7^W?F2j zRu~^S7ugf6L;UV1u$MVE>S!fSG(7oeP?!%L1{nbh9?8)SwJC@3cl>un!>LGtv>I{z zXuoJ)0#IsKY$l|cFGl^*$U>rUn2i4#5bN@UMhpZspTyC~;LzJPxdwZ`vika)*rk`) zm&Q2)Exl1(W%ZcWoIu04k}ZHdrpqS69T@z{f}eFt@*B9WakwcOj!1$O`5C7L`e6F<6i<%~6K!f?5HE zhsVMKFi!!xr+jj963F_Yho%G}1lNAUh4&%)}^`SVq)#ewWp`BoL~q!7OvE+xaY+~?cGDvbdO$zqJFC8B{IY$F$gO# z2wH zO(^t|2UyRw)tp*$+luDP=T6Z@cd#Bb9Y=N41P!O$v3`az2%Jf@nil%f7hnA(1RqX8 z>+H@&?EP(;OqrB^rk(l@^EJjH60kyL-S!;GGJ$|#|5MU4=}n2geC`GNO2 zW?y&+4x5nPVzxqgzb~t8Ed|XUs-$)E@P=ft%C=hmkT54~G&x3~+DqFr6A78F_wPA7 zW~uOL3@9wtV>NN%E;6EfIPO63GBZV_ikStuq=IL89k$ak0h~e9@WFkXv-ZRYs*1Oi zglB};QEzI^+$+K^g@VHp-WMGpMn;>1@WP!Kg9SYR!>{0}KkAO?Bf$lSsJt5~ADR$dUhwLK2BmzbbOF(Z{1gQ?M#&$?^5v>T+?Dz0v=td-!agTt%

)CuVFg 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%0AKHI)QJZ4qK%5u;3& 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

0k0$ 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%0De9gdcu-Fl(iQ8S>M5rpHJ%1YudW{)eajDB7 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 z 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

_gT>~S_7^L=qPJ1N3vINtx2 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 ToCaWEkOsmpK-N#7G6n_!z{1~7 literal 0 HcmV?d00001 diff --git a/main/urls.py b/main/urls.py index a2dcf7bb2..76960378a 100644 --- a/main/urls.py +++ b/main/urls.py @@ -199,6 +199,7 @@ 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" From 05ff56e2524dacbb7518310657b3d0b4d34e9473 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 5 Jan 2026 23:21:50 +0545 Subject: [PATCH 45/57] refactor(export): Decoupling pdf export of playwright - Fix appeal failing test cases(use mocking timezone) --- api/tasks.py | 9 --------- api/test_views.py | 3 +-- eap/admin.py | 9 ++++++++- eap/serializers.py | 4 +--- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/api/tasks.py b/api/tasks.py index 15752c2ca..411473b64 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -1,15 +1,8 @@ -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 @@ -17,7 +10,6 @@ from .logger import logger from .models import Export -from .utils import DebugPlaywright def build_export_filename(export: Export, title: str) -> str: @@ -33,7 +25,6 @@ def build_export_filename(export: Export, title: str) -> str: return f"{prefix} {title} ({timestamp}).pdf" - @shared_task def generate_export_pdf(export_id, title, set_user_language="en"): export = Export.objects.get(id=export_id) diff --git a/api/test_views.py b/api/test_views.py index 263f45534..a116754a0 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -1,7 +1,6 @@ +import datetime import re import uuid -import datetime -from django.utils import timezone from unittest.mock import patch from django.contrib.auth.models import User diff --git a/eap/admin.py b/eap/admin.py index 2ba116277..9f3d89bf5 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -1,7 +1,14 @@ from django.contrib import admin from django.db import transaction -from eap.models import EAPFile, EAPRegistration, EAPType, FullEAP, KeyActor, SimplifiedEAP +from eap.models import ( + EAPFile, + EAPRegistration, + EAPType, + FullEAP, + KeyActor, + SimplifiedEAP, +) @admin.register(EAPFile) diff --git a/eap/serializers.py b/eap/serializers.py index bd6a00719..1c092f80a 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,7 +1,7 @@ import typing -from django.db import transaction 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 @@ -400,8 +400,6 @@ class Meta: fields = "__all__" - - class EAPContactSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) From cbbf8c3616ca071b2ba9a5766a382e076a3ea33f Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 6 Jan 2026 16:17:33 +0545 Subject: [PATCH 46/57] feat(eap): Add export file generation and retrigger action on adminpanel --- eap/admin.py | 34 +++++++++++++++++++++++ eap/serializers.py | 62 ++++++++++++++++++++++++++++-------------- eap/tasks.py | 68 +++++++++++++++++++++++++++++++++++----------- eap/test_views.py | 16 +++++++++++ 4 files changed, 144 insertions(+), 36 deletions(-) diff --git a/eap/admin.py b/eap/admin.py index 9f3d89bf5..d7bb6fc2a 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -106,8 +106,25 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): ) 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. @@ -192,8 +209,25 @@ class FullEAPAdmin(admin.ModelAdmin): ) 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. diff --git a/eap/serializers.py b/eap/serializers.py index 1c092f80a..a59e7a1d7 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,5 +1,6 @@ import typing +from celery import group from django.contrib.auth.models import User from django.db import transaction from django.utils import timezone @@ -34,7 +35,11 @@ TimeFrame, YearsTimeFrameChoices, ) -from eap.tasks import generate_eap_summary_pdf, generate_export_diff_pdf +from eap.tasks import ( + generate_eap_summary_pdf, + generate_export_diff_pdf, + generate_export_eap_pdf, +) from eap.utils import ( has_country_permission, is_user_ifrc_admin, @@ -418,10 +423,10 @@ class CommonEAPFieldsSerializer(serializers.ModelSerializer): MAX_NUMBER_OF_IMAGES = 5 # Partner NS Contact - partner_contacts = EAPContactSerializer(many=True, required=False) + partner_contacts = EAPContactSerializer(many=True, required=False, allow_null=True) - planned_operations = PlannedOperationSerializer(many=True, required=False) - enable_approaches = EnableApproachSerializer(many=True, required=False) + planned_operations = PlannedOperationSerializer(many=True, required=True) + enable_approaches = EnableApproachSerializer(many=True, required=True) # FILES cover_image_file = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) @@ -434,8 +439,8 @@ def get_fields(self): # 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=False) - fields["enable_approaches"] = EnableApproachSerializer(many=True, required=False) + fields["planned_operations"] = PlannedOperationSerializer(many=True, required=True) + fields["enable_approaches"] = EnableApproachSerializer(many=True, required=True) fields["budget_file_details"] = EAPFileSerializer(source="budget_file", read_only=True) return fields @@ -598,8 +603,8 @@ class FullEAPSerializer( # admins key_actors = KeyActorSerializer(many=True, required=True) - early_actions = EAPActionSerializer(many=True, required=False) - prioritized_impacts = ImpactSerializer(many=True, required=False) + 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) @@ -822,6 +827,8 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t 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"]) elif (current_status, new_status) == ( @@ -858,12 +865,20 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) + # Generating PDFs asynchronously transaction.on_commit( - lambda: generate_export_diff_pdf.delay( - eap_registration_id=self.instance.id, - version=self.instance.latest_simplified_eap.version, - ) + 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( @@ -871,11 +886,18 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) + # Generating PDFs asynchronously transaction.on_commit( - lambda: generate_export_diff_pdf.delay( - eap_registration_id=self.instance.id, - version=self.instance.latest_full_eap.version, - ) + 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) == ( @@ -901,6 +923,10 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ] ) + # 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, @@ -913,10 +939,6 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ] ) - # 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.APPROVED, EAPRegistration.Status.ACTIVATED, diff --git a/eap/tasks.py b/eap/tasks.py index c01d158b6..481c724c9 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -14,7 +14,7 @@ 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 Summary {title} ({timestamp}).pdf" + return f"EAP-{title}-({timestamp}).pdf" @shared_task @@ -38,11 +38,6 @@ def generate_eap_summary_pdf(eap_registration_id): file_name = build_filename(eap_registration) eap_registration.summary_file.save(file_name, file) - eap_registration.save( - update_fields=[ - "summary_file", - ], - ) logger.info(f"EAP summary generation completed: {eap_registration.pk}") @@ -86,11 +81,6 @@ def generate_export_diff_pdf(eap_registration_id, version): raise ValueError("Simplified EAP version not found.") simplified_eap.diff_file.save(file_name, file) - simplified_eap.save( - update_fields=[ - "diff_file", - ], - ) else: full_eap = FullEAP.objects.filter( eap_registration=eap_registration, @@ -100,11 +90,6 @@ def generate_export_diff_pdf(eap_registration_id, version): raise ValueError("Full EAP version not found.") full_eap.diff_file.save(file_name, file) - full_eap.save( - update_fields=[ - "diff_file", - ], - ) logger.info(f"EAP diff generation completed: {eap_registration.pk}") @@ -116,3 +101,54 @@ def generate_export_diff_pdf(eap_registration_id, version): 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), + ), + ) diff --git a/eap/test_views.py b/eap/test_views.py index 0115a95bd..4524e7a7a 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1930,6 +1930,22 @@ def test_create_full_eap(self): "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", From 3097fd3c653f743c651209c62455a408fd7b98f6 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 7 Jan 2026 14:14:39 +0545 Subject: [PATCH 47/57] feat(eap): Add previous_id feature on snapshot creation --- eap/migrations/0014_eapcontact_and_more.py | 64 +++++++++++++++++++++- eap/models.py | 14 +++++ eap/serializers.py | 12 ++-- eap/test_views.py | 12 ++++ eap/utils.py | 21 ++++--- eap/views.py | 2 + 6 files changed, 109 insertions(+), 16 deletions(-) diff --git a/eap/migrations/0014_eapcontact_and_more.py b/eap/migrations/0014_eapcontact_and_more.py index 8a5433af5..1e85e7dd5 100644 --- a/eap/migrations/0014_eapcontact_and_more.py +++ b/eap/migrations/0014_eapcontact_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2026-01-05 16:16 +# Generated by Django 4.2.26 on 2026-01-07 08:22 from django.db import migrations, models import main.fields @@ -45,6 +45,12 @@ class Migration(migrations.Migration): verbose_name="Contact Phone Number", ), ), + ( + "previous_id", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), ], options={ "verbose_name": "EAP Contact", @@ -87,6 +93,20 @@ class Migration(migrations.Migration): model_name="simplifiedeap", name="partner_ns_title", ), + migrations.AddField( + model_name="eapaction", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + migrations.AddField( + model_name="eapimpact", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), migrations.AddField( model_name="eapregistration", name="summary_file", @@ -97,6 +117,13 @@ class Migration(migrations.Migration): verbose_name="EAP Summary PDF", ), ), + migrations.AddField( + model_name="enableapproach", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), migrations.AddField( model_name="fulleap", name="diff_file", @@ -127,6 +154,34 @@ class Migration(migrations.Migration): verbose_name="Review Checklist File", ), ), + migrations.AddField( + model_name="indicator", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + migrations.AddField( + model_name="keyactor", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + migrations.AddField( + model_name="operationactivity", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), + migrations.AddField( + model_name="plannedoperation", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), migrations.AddField( model_name="simplifiedeap", name="diff_file", @@ -157,6 +212,13 @@ class Migration(migrations.Migration): verbose_name="Review Checklist File", ), ), + migrations.AddField( + model_name="sourceinformation", + name="previous_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Previous ID" + ), + ), migrations.AddField( model_name="fulleap", name="partner_contacts", diff --git a/eap/models.py b/eap/models.py index a52e0cccd..04c8085c5 100644 --- a/eap/models.py +++ b/eap/models.py @@ -237,6 +237,7 @@ class EAPContact(models.Model): 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") @@ -328,6 +329,7 @@ class OperationActivity(models.Model): 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") @@ -340,6 +342,7 @@ def __str__(self): 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") @@ -351,6 +354,7 @@ def __str__(self): 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") @@ -362,6 +366,7 @@ def __str__(self): 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") @@ -392,6 +397,7 @@ class Sector(models.IntegerChoices): 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, @@ -437,6 +443,7 @@ class Approach(models.IntegerChoices): 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, @@ -482,6 +489,11 @@ class SourceInformation(models.Model): 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") @@ -505,6 +517,8 @@ class KeyActor(models.Model): 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") diff --git a/eap/serializers.py b/eap/serializers.py index a59e7a1d7..fac1e7e4d 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -333,6 +333,7 @@ class PlannedOperationSerializer( ): id = serializers.IntegerField(required=False) + sector_display = serializers.CharField(source="get_sector_display", read_only=True) indicators = IndicatorSerializer(many=True, required=True) # activities @@ -352,6 +353,7 @@ class EnableApproachSerializer( ): id = serializers.IntegerField(required=False) + approach_display = serializers.CharField(source="get_approach_display", read_only=True) indicators = IndicatorSerializer(many=True, required=True) # activities @@ -410,13 +412,7 @@ class EAPContactSerializer(serializers.ModelSerializer): class Meta: model = EAPContact - fields = ( - "id", - "title", - "name", - "email", - "phone_number", - ) + fields = "__all__" class CommonEAPFieldsSerializer(serializers.ModelSerializer): @@ -431,6 +427,7 @@ class CommonEAPFieldsSerializer(serializers.ModelSerializer): # FILES cover_image_file = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) + budget_file = serializers.PrimaryKeyRelatedField(queryset=EAPFile.objects.all(), required=True) budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) def get_fields(self): @@ -441,6 +438,7 @@ def get_fields(self): fields["cover_image_file"] = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) fields["planned_operations"] = PlannedOperationSerializer(many=True, required=True) fields["enable_approaches"] = EnableApproachSerializer(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) return fields diff --git a/eap/test_views.py b/eap/test_views.py index 4524e7a7a..a20688f86 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -2380,6 +2380,18 @@ def test_snapshot_full_eap(self): 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.enable_approaches.all(), snapshot.enable_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): diff --git a/eap/utils.py b/eap/utils.py index dbc72fe18..0a1806a9f 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -74,12 +74,12 @@ def copy_model_instance( key = (instance.__class__, instance.pk) - # already cloned → return that clone + # already cloned -> return that clone if key in clone_cache: return clone_cache[key] opts = instance._meta - data = {} + data: Dict[str, Any] = {} # Cloning standard fields for field in opts.fields: @@ -91,7 +91,7 @@ def copy_model_instance( data.update(overrides) - clone = instance.__class__.objects.create(**data) + clone: T = instance.__class__.objects.create(**data) # NOTE: Register the clone in cache before cloning M2M to handle circular references clone_cache[key] = clone @@ -105,15 +105,20 @@ def copy_model_instance( continue related = getattr(instance, name).all() - cloned_related = [ - copy_model_instance( + 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=None, + overrides=overrides_obj, exclude_clone_m2m_fields=exclude_m2m, clone_cache=clone_cache, ) - for obj in related - ] + cloned_related.append(cloned_obj) getattr(clone, name).set(cloned_related) diff --git a/eap/views.py b/eap/views.py index dcb13db58..27bd0cb61 100644 --- a/eap/views.py +++ b/eap/views.py @@ -182,6 +182,7 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: ) .prefetch_related( "eap_registration__partners", + "partner_contacts", "admin2", Prefetch( "planned_operations", @@ -239,6 +240,7 @@ def get_queryset(self) -> QuerySet[FullEAP]: ) .prefetch_related( "admin2", + "partner_contacts", "prioritized_impacts", "early_actions", # source information From 309fffe66b9418e4ebd0d37350339d80f2d949c5 Mon Sep 17 00:00:00 2001 From: Sudip Khanal <101724348+sudip-khanal@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:15:11 +0545 Subject: [PATCH 48/57] EAP: email notification setup (#2624) --- deploy/helm/ifrcgo-helm/values.yaml | 2 + eap/dev_views.py | 123 +++++ .../commands/eap_submission_reminder.py | 34 ++ .../0015_eapregistration_deadline_and_more.py | 23 + eap/models.py | 15 + eap/serializers.py | 126 +++++ eap/tasks.py | 463 +++++++++++++++++- eap/test_views.py | 302 +++++++++++- eap/utils.py | 119 +++++ main/sentry.py | 1 + main/settings.py | 18 + main/urls.py | 2 + notifications/notification.py | 30 +- .../templates/email/eap/approved.html | 33 ++ .../eap/feedback_to_national_society.html | 51 ++ .../email/eap/feedback_to_revised_eap.html | 57 +++ .../templates/email/eap/pending_pfa.html | 48 ++ .../templates/email/eap/re-submission.html | 114 +++++ .../templates/email/eap/registration.html | 54 ++ .../templates/email/eap/reminder.html | 29 ++ .../templates/email/eap/submission.html | 84 ++++ .../email/eap/technically_validated_eap.html | 38 ++ 22 files changed, 1753 insertions(+), 13 deletions(-) create mode 100644 eap/dev_views.py create mode 100644 eap/management/commands/eap_submission_reminder.py create mode 100644 eap/migrations/0015_eapregistration_deadline_and_more.py create mode 100644 notifications/templates/email/eap/approved.html create mode 100644 notifications/templates/email/eap/feedback_to_national_society.html create mode 100644 notifications/templates/email/eap/feedback_to_revised_eap.html create mode 100644 notifications/templates/email/eap/pending_pfa.html create mode 100644 notifications/templates/email/eap/re-submission.html create mode 100644 notifications/templates/email/eap/registration.html create mode 100644 notifications/templates/email/eap/reminder.html create mode 100644 notifications/templates/email/eap/submission.html create mode 100644 notifications/templates/email/eap/technically_validated_eap.html diff --git a/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 5177d8948..bca0bf244 100644 --- a/deploy/helm/ifrcgo-helm/values.yaml +++ b/deploy/helm/ifrcgo-helm/values.yaml @@ -279,6 +279,8 @@ cronjobs: # https://github.com/jazzband/django-oauth-toolkit/blob/master/docs/management_commands.rst#cleartokens - command: 'oauth_cleartokens' schedule: '0 1 * * *' + - command: 'eap_submission_reminder' + schedule: '0 0 * * *' elasticsearch: diff --git a/eap/dev_views.py b/eap/dev_views.py new file mode 100644 index 000000000..aa7c9617c --- /dev/null +++ b/eap/dev_views.py @@ -0,0 +1,123 @@ +from django.http import HttpResponse +from django.template import loader +from rest_framework import permissions +from rest_framework.views import APIView + + +class EAPEmailPreview(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + type_param = request.GET.get("type") + + template_map = { + "registration": "email/eap/registration.html", + "submission": "email/eap/submission.html", + "feedback_to_national_society": "email/eap/feedback_to_national_society.html", + "resubmission_of_revised_eap": "email/eap/re-submission.html", + "feedback_for_revised_eap": "email/eap/feedback_to_revised_eap.html", + "technically_validated_eap": "email/eap/technically_validated_eap.html", + "pending_pfa": "email/eap/pending_pfa.html", + "approved_eap": "email/eap/approved.html", + "reminder": "email/eap/reminder.html", + } + + if type_param not in template_map: + valid_values = ", ".join(template_map.keys()) + return HttpResponse( + f"Invalid 'type' parameter. Please use one of the following values: {valid_values}.", + ) + + context_map = { + "registration": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "supporting_partners": [ + {"society_name": "Partner 1"}, + {"society_name": "Partner 2"}, + ], + "disaster_type": "Flood", + "ns_contact_name": "Test registration name", + "ns_contact_email": "test.registration@example.com", + "ns_contact_phone": "1234567890", + }, + "submission": { + "eap_type_display": "SIMPLIFIED EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "people_targated": 100, + "latest_eap_id": 1, + "supporting_partners": [ + {"society_name": "Partner NS 1"}, + {"society_name": "Partner NS 2"}, + ], + "disaster_type": "Flood", + "total_budget": "250,000 CHF", + "ns_contact_name": "Test Ns Contact name", + "ns_contact_email": "test.Ns@gmail.com", + "ns_contact_phone": "+977-9800000000", + }, + "feedback_to_national_society": { + "registration_id": 1, + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + }, + "resubmission_of_revised_eap": { + "latest_eap_id": 1, + "eap_type_display": "SIMPLIFIED EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "supporting_partners": [ + {"society_name": "Partner NS 1"}, + {"society_name": "Partner NS 2"}, + ], + "version": 2 or 3, + "people_targated": 100, + "disaster_type": "Flood", + "total_budget": "250,000 CHF", + "ns_contact_name": "Test Ns Contact name", + "ns_contact_email": "test.Ns@gmail.com", + "ns_contact_phone": "+977-9800000000", + }, + "feedback_for_revised_eap": { + "registration_id": 1, + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "version": 2, + }, + "technically_validated_eap": { + "registration_id": 1, + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "pending_pfa": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "approved_eap": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "reminder": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + } + + context = context_map.get(type_param) + if context is None: + return HttpResponse("No context found for the email preview.") + template_file = template_map[type_param] + template = loader.get_template(template_file) + return HttpResponse(template.render(context, request)) diff --git a/eap/management/commands/eap_submission_reminder.py b/eap/management/commands/eap_submission_reminder.py new file mode 100644 index 000000000..fa0a47e67 --- /dev/null +++ b/eap/management/commands/eap_submission_reminder.py @@ -0,0 +1,34 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone +from sentry_sdk.crons import monitor + +from eap.models import EAPRegistration +from eap.tasks import send_deadline_reminder_email +from main.sentry import SentryMonitor + + +class Command(BaseCommand): + help = "Send EAP submission reminder emails 1 week before deadline" + + @monitor(monitor_slug=SentryMonitor.EAP_SUBMISSION_REMINDER) + def handle(self, *args, **options): + """ + Finds EAP-registrations whose submission deadline is exactly 1 week from today + and sends reminder emails for each matching registration. + """ + target_date = timezone.now().date() + timedelta(weeks=1) + queryset = EAPRegistration.objects.filter( + deadline=target_date, + ) + + if not queryset.exists(): + self.stdout.write(self.style.NOTICE("No EAP registrations found for deadline reminder.")) + return + + for instance in queryset.iterator(): + self.stdout.write(self.style.NOTICE(f"Sending deadline reminder email for EAPRegistration ID={instance.id}")) + send_deadline_reminder_email(instance.id) + + self.stdout.write(self.style.SUCCESS("Successfully sent all deadline reminder emails.")) diff --git a/eap/migrations/0015_eapregistration_deadline_and_more.py b/eap/migrations/0015_eapregistration_deadline_and_more.py new file mode 100644 index 000000000..5da8e7b97 --- /dev/null +++ b/eap/migrations/0015_eapregistration_deadline_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.26 on 2026-01-08 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0014_eapcontact_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='eapregistration', + name='deadline', + field=models.DateField(blank=True, help_text='Date by which the EAP submission must be completed.', null=True, verbose_name='deadline'), + ), + migrations.AddField( + model_name='eapregistration', + name='deadline_remainder_sent_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the deadline reminder email was sent.', null=True, verbose_name='deadline reminder email sent at'), + ), + ] diff --git a/eap/models.py b/eap/models.py index 04c8085c5..d57959470 100644 --- a/eap/models.py +++ b/eap/models.py @@ -722,6 +722,21 @@ class EAPRegistration(EAPBaseModel): help_text=_("Timestamp when the EAP was activated."), ) + # EAP submission deadline + deadline = models.DateField( + null=True, + blank=True, + verbose_name=_("deadline"), + help_text=_("Date by which the EAP submission must be completed."), + ) + + deadline_remainder_sent_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("deadline reminder email sent at"), + help_text=_("Timestamp when the deadline reminder email was sent."), + ) + # TYPING id: int national_society_id: int diff --git a/eap/serializers.py b/eap/serializers.py index fac1e7e4d..0dc131ed8 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,4 +1,5 @@ import typing +from datetime import timedelta from celery import group from django.contrib.auth.models import User @@ -39,6 +40,14 @@ generate_eap_summary_pdf, generate_export_diff_pdf, generate_export_eap_pdf, + send_approved_email, + send_eap_resubmission_email, + send_feedback_email, + send_feedback_email_for_resubmitted_eap, + send_new_eap_registration_email, + send_new_eap_submission_email, + send_pending_pfa_email, + send_technical_validation_email, ) from eap.utils import ( has_country_permission, @@ -189,8 +198,19 @@ class Meta: "modified_by", "latest_simplified_eap", "latest_full_eap", + "deadline", ] + def create(self, validated_data: dict[str, typing.Any]): + instance = super().create(validated_data) + + transaction.on_commit( + lambda: send_new_eap_registration_email.delay( + instance.id, + ) + ) + return instance + def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: # NOTE: Cannot update once EAP application is being created. if instance.has_eap_application: @@ -962,3 +982,109 @@ def validate_review_checklist_file(self, file): validate_file_type(file) return file + + def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> EAPRegistration: + old_status = instance.get_status_enum + updated_instance = super().update(instance, validated_data) + new_status = updated_instance.get_status_enum + + if old_status == new_status: + return updated_instance + + eap_registration_id = updated_instance.id + assert updated_instance.get_eap_type_enum is not None, "EAP type must not be None" + + if updated_instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + eap_count = SimplifiedEAP.objects.filter(eap_registration=updated_instance).count() + else: + eap_count = FullEAP.objects.filter(eap_registration=updated_instance).count() + + if (old_status, new_status) == ( + EAPRegistration.Status.UNDER_DEVELOPMENT, + EAPRegistration.Status.UNDER_REVIEW, + ): + transaction.on_commit(lambda: send_new_eap_submission_email.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.UNDER_REVIEW, + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + ): + """ + NOTE: + At the transition (UNDER_REVIEW -> NS_ADDRESSING_COMMENTS), the EAP snapshot + is generated inside `_validate_status()` BEFORE we reach this `update()` method. + + That snapshot operation: + - Locks the reviewed EAP (previous version) + - Creates a new snapshot (incremented version) + - Updates latest_simplified_eap or latest_full_eap to the new version + + Email logic based on eap_count: + - If eap_count == 2 (i.e., first snapshot already exists and this is the first IFRC feedback cycle) + - Send the first feedback email + - Else (eap_count > 2), indicating subsequent feedback cycles: + - Send the resubmitted feedback email + + Therefore: + - version == 2 always corresponds to the first IFRC feedback cycle + - Any later versions (>= 3) correspond to resubmitted cycles + + Deadline update rules: + - First IFRC feedback cycle: deadline is set to 90 days from the current date. + - Subsequent feedback or resubmission cycles: deadline is set to 30 days from the current date. + """ + + if eap_count == 2: + updated_instance.deadline = timezone.now().date() + timedelta(days=90) + updated_instance.save( + update_fields=[ + "deadline", + ] + ) + transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id)) + + elif eap_count > 2: + updated_instance.deadline = timezone.now().date() + timedelta(days=30) + updated_instance.save( + update_fields=[ + "deadline", + ] + ) + transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + EAPRegistration.Status.UNDER_REVIEW, + ): + transaction.on_commit(lambda: send_eap_resubmission_email.delay(eap_registration_id)) + elif (old_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + ): + updated_instance.deadline = timezone.now().date() + timedelta(days=30) + updated_instance.save( + update_fields=[ + "deadline", + ] + ) + transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.UNDER_REVIEW, + EAPRegistration.Status.TECHNICALLY_VALIDATED, + ): + transaction.on_commit(lambda: send_technical_validation_email.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.PENDING_PFA, + ): + transaction.on_commit(lambda: send_pending_pfa_email.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.PENDING_PFA, + EAPRegistration.Status.APPROVED, + ): + transaction.on_commit(lambda: send_approved_email.delay(eap_registration_id)) + + return updated_instance diff --git a/eap/tasks.py b/eap/tasks.py index 481c724c9..2577e33a1 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -1,14 +1,23 @@ from datetime import datetime from celery import shared_task +from django.conf import settings from django.contrib.auth.models import User +from django.template.loader import render_to_string +from django.utils import timezone from rest_framework.authtoken.models import Token from api.logger import logger from api.playwright import render_pdf_from_url from api.utils import generate_eap_export_url from eap.models import EAPRegistration, EAPType, FullEAP, SimplifiedEAP +from eap.utils import ( + get_coordinator_emails_by_region, + get_eap_email_context, + get_eap_registration_email_context, +) from main.utils import logger_context +from notifications.notification import send_notification def build_filename(eap_registration: EAPRegistration) -> str: @@ -108,7 +117,6 @@ def generate_export_eap_pdf(eap_registration_id, version): eap_registration = EAPRegistration.objects.get(id=eap_registration_id) user = User.objects.get(id=eap_registration.created_by_id) token = Token.objects.filter(user=user).last() - url = generate_eap_export_url( registration_id=eap_registration_id, version=version, @@ -152,3 +160,456 @@ def generate_export_eap_pdf(eap_registration_id, version): dict(eap_registration_id=eap_registration.pk), ), ) + + +@shared_task +def send_new_eap_registration_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + instance.ifrc_contact_email, + ] + cc_recipients = list( + set( + [ + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_registration_email_context(instance) + email_subject = ( + f"[{instance.get_eap_type_display() if instance.get_eap_type_display() else 'EAP'} IN DEVELOPMENT] " + f"{instance.country} {instance.disaster_type}" + ) + email_body = render_to_string("email/eap/registration.html", email_context) + email_type = "New EAP Registration" + + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_new_eap_submission_email(eap_registration_id: int): + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + if not latest_eap.export_file: + generate_export_eap_pdf( + eap_registration_id=instance.id, + version=latest_eap.version, + ) + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + instance.ifrc_contact_email, + ] + cc_recipients = list( + set( + [ + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FOR REVIEW] " f"{instance.country} {instance.disaster_type} TO THE IFRC-DREF" + ) + email_body = render_to_string("email/eap/submission.html", email_context) + email_type = "EAP Submission" + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_feedback_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + ifrc_delegation_focal_point_email = latest_eap.ifrc_delegation_focal_point_email + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + ifrc_delegation_focal_point_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FEEDBACK] " + f"{instance.country} {instance.disaster_type} TO THE {instance.national_society}" + ) + email_body = render_to_string("email/eap/feedback_to_national_society.html", email_context) + email_type = "Feedback to the National Society" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_eap_resubmission_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + latest_version = latest_eap.version + + if not latest_eap.diff_file: + generate_export_diff_pdf( + eap_registration_id=instance.id, + version=latest_eap.version, + ) + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + instance.ifrc_contact_email, + ] + + cc_recipients = list( + set( + [ + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FOR REVIEW] " + f"{instance.country} {instance.disaster_type} version {latest_version} TO THE IFRC-DREF" + ) + email_body = render_to_string("email/eap/re-submission.html", email_context) + email_type = "Feedback to the National Society" + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_feedback_email_for_resubmitted_eap(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + eap_model = SimplifiedEAP + else: + latest_eap = instance.latest_full_eap + eap_model = FullEAP + + latest_version = latest_eap.version + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + previous_eap = ( + eap_model.objects.filter( + eap_registration=instance, + version__lt=latest_version, + ) + .order_by("-version") + .first() + ) + + previous_version = previous_eap.version if previous_eap else None + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FEEDBACK] " + f"{instance.country} {instance.disaster_type} version {previous_version} TO {instance.national_society}" + ) + email_body = render_to_string("email/eap/feedback_to_revised_eap.html", email_context) + email_type = "Feedback to the National Society" + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_technical_validation_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + partner_contacts = ( + instance.latest_simplified_eap.partner_contacts + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP + else instance.latest_full_eap.partner_contacts + ) + + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} TECHNICALLY VALIDATED] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/technically_validated_eap.html", email_context) + email_type = "Technically Validated EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context + + +@shared_task +def send_pending_pfa_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap = instance.latest_simplified_eap + else: + latest_eap = instance.latest_full_eap + + if not latest_eap.summary_file: + generate_eap_summary_pdf( + eap_registration_id=instance.id, + ) + + partner_contacts = latest_eap.partner_contacts + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED PENDING PFA] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/pending_pfa.html", email_context) + email_type = "Approved Pending PFA EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context + + +@shared_task +def send_approved_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + partner_contacts = ( + instance.latest_simplified_eap.partner_contacts + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP + else instance.latest_full_eap.partner_contacts + ) + + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/approved.html", email_context) + email_type = "Approved EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context + + +@shared_task +def send_deadline_reminder_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + partner_contacts = ( + instance.latest_simplified_eap.partner_contacts + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP + else instance.latest_full_eap.partner_contacts + ) + + partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + *partner_ns_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} SUBMISSION REMINDER] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/reminder.html", email_context) + email_type = "Approved EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + instance.deadline_remainder_sent_at = timezone.now() + instance.save(update_fields=["deadline_remainder_sent_at"]) + + return email_context diff --git a/eap/test_views.py b/eap/test_views.py index a20688f86..f4f76718d 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -122,7 +122,8 @@ def test_list_eap_registration(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["results"]), 5) - def test_create_eap_registration(self): + @mock.patch("eap.tasks.send_new_eap_registration_email") + def test_create_eap_registration(self, send_new_eap_registration_email): url = "/api/v2/eap-registration/" data = { "eap_type": EAPType.FULL_EAP, @@ -159,6 +160,7 @@ def test_create_eap_registration(self): self.disaster_type.id, }, ) + self.assertTrue(send_new_eap_registration_email) def test_retrieve_eap_registration(self): eap_registration = EAPRegistrationFactory.create( @@ -1653,6 +1655,304 @@ def test_status_transition(self): response = self.client.patch(url, update_data, format="json") self.assertEqual(response.status_code, 400) + @mock.patch("eap.serializers.send_new_eap_submission_email") + @mock.patch("eap.serializers.send_feedback_email") + @mock.patch("eap.serializers.send_eap_resubmission_email") + @mock.patch("eap.serializers.send_technical_validation_email") + @mock.patch("eap.serializers.send_feedback_email_for_resubmitted_eap") + @mock.patch("eap.serializers.send_pending_pfa_email") + @mock.patch("eap.serializers.send_approved_email") + def test_status_transitions_trigger_email( + self, + send_approved_email, + send_pending_pfa_email, + send_feedback_email_for_resubmitted_eap, + send_technical_validation_email, + send_eap_resubmission_email, + send_feedback_email, + send_new_eap_submission_email, + ): + + # Create permissions + management.call_command("make_permissions") + + self.country_admin = UserFactory.create() + country_admin_permission = Permission.objects.filter(codename="country_admin_%s" % self.national_society.id).first() + country_group = Group.objects.filter(name="%s Admins" % self.national_society.name).first() + + self.country_admin.user_permissions.add(country_admin_permission) + self.country_admin.groups.add(country_group) + + # Create IFRC Admin User and assign permission + self.ifrc_admin_user = UserFactory.create() + ifrc_admin_permission = Permission.objects.filter(codename="ifrc_admin").first() + ifrc_group = Group.objects.filter(name="IFRC Admins").first() + self.ifrc_admin_user.user_permissions.add(ifrc_admin_permission) + self.ifrc_admin_user.groups.add(ifrc_group) + + eap_registration = EAPRegistrationFactory.create( + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + eap_type=EAPType.SIMPLIFIED_EAP, + status=EAPStatus.UNDER_DEVELOPMENT, + partners=[self.partner1.id, self.partner2.id], + created_by=self.user, + modified_by=self.user, + ) + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=eap_registration, + created_by=self.country_admin, + modified_by=self.country_admin, + budget_file=EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ), + ) + eap_registration.latest_simplified_eap = simplified_eap + eap_registration.save() + + url = f"/api/v2/eap-registration/{eap_registration.id}/status/" + + # UNDER_DEVELOPMENT -> UNDER_REVIEW + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_new_eap_submission_email.delay.assert_called_once_with(eap_registration.id) + send_new_eap_submission_email.delay.reset_mock() + + # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + } + # Login as IFRC admin user + self.authenticate(self.ifrc_admin_user) + + # Upload checklist and change status in a single request + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + "review_checklist_file": tmp_file, + } + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="multipart") + + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + send_feedback_email.delay.assert_called_once_with(eap_registration.id) + send_feedback_email.delay.reset_mock() + # ----------------------------- + # Check snapshots after the status change + # ----------------------------- + snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() + assert snapshot is not None, "Snapshot should exist now" + eap_registration.latest_simplified_eap = snapshot + eap_registration.save() + + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + # Upload updated checklist file + # UPDATES on the second snapshot + snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/" + checklist_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "eap_registration": snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + self.authenticate(self.country_admin) + response = self.client.patch(snapshot_url, file_data, format="json") + self.assert_200(response) + + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id) + send_eap_resubmission_email.delay.reset_mock() + + # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS + # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS + # Login as IFRC admin user + self.authenticate(self.ifrc_admin_user) + + # Upload checklist and change status in a single request + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + "review_checklist_file": tmp_file, + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="multipart") + + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id) + send_feedback_email_for_resubmitted_eap.delay.reset_mock() + # ----------------------------- + # Check snapshots after the status change + # ----------------------------- + snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() + assert snapshot is not None, "Snapshot should exist now" + eap_registration.latest_simplified_eap = snapshot + eap_registration.save() + + # NOTE: Again Transition to UNDER_REVIEW + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/" + checklist_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "eap_registration": snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + self.authenticate(self.country_admin) + response = self.client.patch(snapshot_url, file_data, format="json") + self.assert_200(response) + + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id) + send_eap_resubmission_email.delay.reset_mock() + + # Transition UNDER_REVIEW -> TECHNICALLY_VALIDATED + data = {"status": EAPStatus.TECHNICALLY_VALIDATED} + self.authenticate(self.ifrc_admin_user) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED) + eap_registration.refresh_from_db() + send_technical_validation_email.delay.assert_called_once_with(eap_registration.id) + send_technical_validation_email.delay.reset_mock() + + # Transition TECHNICALLY_VALIDATED -> NS_ADDRESSING_COMMENTS + # Login as IFRC admin user + self.authenticate(self.ifrc_admin_user) + + # Upload checklist and change status in a single request + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + "review_checklist_file": tmp_file, + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="multipart") + + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id) + send_feedback_email_for_resubmitted_eap.delay.reset_mock() + # ----------------------------- + # Check snapshots after the status change + # ----------------------------- + snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() + assert snapshot is not None, "Snapshot should exist now" + simplified_eap.refresh_from_db() + eap_registration.latest_simplified_eap = snapshot + eap_registration.save() + + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + # Upload updated checklist file + # UPDATES on the second snapshot + snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/" + checklist_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "eap_registration": snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + self.authenticate(self.country_admin) + response = self.client.patch(snapshot_url, file_data, format="json") + self.assert_200(response) + + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id) + send_eap_resubmission_email.delay.reset_mock() + + # Again Transition UNDER_REVIEW -> TECHNICALLY_VALIDATED + data = {"status": EAPStatus.TECHNICALLY_VALIDATED} + self.authenticate(self.ifrc_admin_user) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED) + send_technical_validation_email.delay.assert_called_once_with(eap_registration.id) + send_technical_validation_email.delay.reset_mock() + + # Transition TECHNICALLY_VALIDATED -> PENDING_PFA + # Upload validated budget file + data = {"status": EAPStatus.PENDING_PFA} + upload_url = f"/api/v2/eap-registration/{eap_registration.id}/upload-validated-budget-file/" + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + file_data = {"validated_budget_file": tmp_file} + self.authenticate(self.ifrc_admin_user) + response = self.client.post(upload_url, file_data, format="multipart") + self.assert_200(response) + + # Now change status → PENDING_PFA + status_url = f"/api/v2/eap-registration/{eap_registration.id}/status/" + data = {"status": EAPStatus.PENDING_PFA} + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(status_url, data, format="json") + self.assert_200(response) + self.assertEqual(response.data["status"], EAPStatus.PENDING_PFA) + eap_registration.refresh_from_db() + send_pending_pfa_email.delay.assert_called_once_with(eap_registration.id) + + # Transition PENDING_PFA -> APPROVED + data = {"status": EAPStatus.APPROVED} + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + self.assertEqual(response.data["status"], EAPStatus.APPROVED) + eap_registration.refresh_from_db() + send_approved_email.delay.assert_called_once_with(eap_registration.id) + class EAPPDFExportTestCase(APITestCase): def setUp(self): diff --git a/eap/utils.py b/eap/utils.py index 0a1806a9f..04f25d5fa 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,10 +1,129 @@ import os from typing import Any, Dict, Set, TypeVar +from django.conf import settings from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError from django.db import models +from api.models import Region, RegionName +from eap.models import EAPType, FullEAP, SimplifiedEAP + +REGION_EMAIL_MAP: dict[RegionName, list[str]] = { + RegionName.AFRICA: settings.EMAIL_EAP_AFRICA_COORDINATORS, + RegionName.AMERICAS: settings.EMAIL_EAP_AMERICAS_COORDINATORS, + RegionName.ASIA_PACIFIC: settings.EMAIL_EAP_ASIA_PACIFIC_COORDINATORS, + RegionName.EUROPE: settings.EMAIL_EAP_EUROPE_COORDINATORS, + RegionName.MENA: settings.EMAIL_EAP_MENA_COORDINATORS, +} + + +def get_coordinator_emails_by_region(region: Region | None) -> list[str]: + """ + This function uses the REGION_EMAIL_MAP dictionary to map Region name to the corresponding list of email addresses. + Args: + region: Region instance for which the coordinator emails are needed. + Returns: + List of email addresses corresponding to the region coordinators. + Returns an empty list if the region is None or not found in the mapping. + """ + if not region: + return [] + + return REGION_EMAIL_MAP.get(region.name, []) + + +def get_file_url(file_obj): + """ + This function returns the URL of a file field if it exists. + Args: + file_obj: A model instance or object containing a file field. + Returns: + str | None: The URL of the file if available, otherwise None. + """ + if not file_obj: + return None + if hasattr(file_obj, "file"): + return file_obj.file.url + + +def get_eap_registration_email_context(instance): + from eap.serializers import EAPRegistrationSerializer + + eap_registration_data = EAPRegistrationSerializer(instance).data + email_context = { + "registration_id": eap_registration_data["id"], + "eap_type_display": eap_registration_data["eap_type_display"], + "country_name": eap_registration_data["country_details"]["name"], + "national_society": eap_registration_data["national_society_details"]["society_name"], + "supporting_partners": eap_registration_data["partners_details"], + "disaster_type": eap_registration_data["disaster_type_details"]["name"], + "ns_contact_name": eap_registration_data["national_society_contact_name"], + "ns_contact_email": eap_registration_data["national_society_contact_email"], + "ns_contact_phone": eap_registration_data["national_society_contact_phone_number"], + "frontend_url": settings.GO_WEB_URL, + } + return email_context + + +def get_eap_email_context(instance): + from eap.serializers import EAPRegistrationSerializer + + eap_registration_data = EAPRegistrationSerializer(instance).data + email_context = { + "registration_id": eap_registration_data["id"], + "eap_type_display": eap_registration_data["eap_type_display"], + "country_name": eap_registration_data["country_details"]["name"], + "national_society": eap_registration_data["national_society_details"]["society_name"], + "supporting_partners": eap_registration_data["partners_details"], + "disaster_type": eap_registration_data["disaster_type_details"]["name"], + "ns_contact_name": eap_registration_data["national_society_contact_name"], + "ns_contact_email": eap_registration_data["national_society_contact_email"], + "ns_contact_phone": eap_registration_data["national_society_contact_phone_number"], + "deadline": eap_registration_data["deadline"], + "frontend_url": settings.GO_WEB_URL, + "validated_budget_file": (instance.validated_budget_file.url if instance.validated_budget_file else None), + "summary_file": (instance.summary_file.url if instance.summary_file else None), + } + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap_data = instance.latest_simplified_eap + eap_model = SimplifiedEAP + else: + latest_eap_data = instance.latest_full_eap + eap_model = FullEAP + + latest_version = latest_eap_data.version + + previous_eap = ( + eap_model.objects.filter( + eap_registration=instance, + version__lt=latest_version, + ) + .order_by("-version") + .first() + ) + + previous_version = previous_eap.version if previous_eap else None + + email_context.update( + { + "latest_eap_id": latest_eap_data.id, + "people_targeted": latest_eap_data.people_targeted, + "total_budget": latest_eap_data.total_budget, + "latest_version": latest_eap_data.version, + "previous_version": previous_version, + "export_file": (latest_eap_data.export_file.url if latest_eap_data.export_file else None), + "diff_file": (latest_eap_data.diff_file.url if latest_eap_data.diff_file else None), + "budget_file": get_file_url(latest_eap_data.budget_file), + "updated_checklist_file": get_file_url(latest_eap_data.updated_checklist_file), + "review_checklist_file": ( + latest_eap_data.review_checklist_file.url if latest_eap_data.review_checklist_file else None + ), + } + ) + return email_context + def has_country_permission(user: User, country_id: int) -> bool: """Checks if the user has country admin permission.""" diff --git a/main/sentry.py b/main/sentry.py index dcb18ee91..183d6c0b3 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -130,6 +130,7 @@ class SentryMonitor(models.TextChoices): INGEST_ICRC = "ingest_icrc", "0 3 * * 0" NOTIFY_VALIDATORS = "notify_validators", "0 0 * * *" OAUTH_CLEARTOKENS = "oauth_cleartokens", "0 1 * * *" + EAP_SUBMISSION_REMINDER = "eap_submission_reminder", "0 0 * * *" @staticmethod def load_cron_data() -> typing.List[typing.Tuple[str, str]]: diff --git a/main/settings.py b/main/settings.py index 6fede598b..2ff0776d6 100644 --- a/main/settings.py +++ b/main/settings.py @@ -68,6 +68,14 @@ EMAIL_USER=(str, None), EMAIL_PASS=(str, None), DEBUG_EMAIL=(bool, False), # This was 0/1 before + # EAP-EMAILS + EMAIL_EAP_DREF_ANTICIPATORY_PILLAR=(str, None), + EMAIL_EAP_DREF_AA_GLOBAL_TEAM=(list, None), + EMAIL_EAP_AFRICA_COORDINATORS=(list, None), + EMAIL_EAP_AMERICAS_COORDINATORS=(list, None), + EMAIL_EAP_ASIA_PACIFIC_COORDINATORS=(list, None), + EMAIL_EAP_EUROPE_COORDINATORS=(list, None), + EMAIL_EAP_MENA_COORDINATORS=(list, None), # TEST_EMAILS=(list, ['im@ifrc.org']), # maybe later # Translation # Translator Available: @@ -198,6 +206,7 @@ def parse_domain(*env_keys: str) -> str: ALLOWED_HOSTS = [ "localhost", "0.0.0.0", + "127.0.0.1", urlparse(GO_API_URL).hostname, *env("DJANGO_ADDITIONAL_ALLOWED_HOSTS"), ] @@ -581,6 +590,15 @@ def parse_domain(*env_keys: str) -> str: DEBUG_EMAIL = env("DEBUG_EMAIL") # TEST_EMAILS = env('TEST_EMAILS') # maybe later +# EAP-Email +EMAIL_EAP_DREF_ANTICIPATORY_PILLAR = env("EMAIL_EAP_DREF_ANTICIPATORY_PILLAR") +EMAIL_EAP_DREF_AA_GLOBAL_TEAM = env("EMAIL_EAP_DREF_AA_GLOBAL_TEAM") +EMAIL_EAP_AFRICA_COORDINATORS = env("EMAIL_EAP_AFRICA_COORDINATORS") +EMAIL_EAP_AMERICAS_COORDINATORS = env("EMAIL_EAP_AMERICAS_COORDINATORS") +EMAIL_EAP_ASIA_PACIFIC_COORDINATORS = env("EMAIL_EAP_ASIA_PACIFIC_COORDINATORS") +EMAIL_EAP_EUROPE_COORDINATORS = env("EMAIL_EAP_EUROPE_COORDINATORS") +EMAIL_EAP_MENA_COORDINATORS = env("EMAIL_EAP_MENA_COORDINATORS") + DATA_UPLOAD_MAX_MEMORY_SIZE = 104857600 # default 2621440, 2.5MB -> 100MB # default 1000, was not enough for Mozambique Cyclone Idai data # second 2000, was not enouch for Global COVID Emergency diff --git a/main/urls.py b/main/urls.py index 76960378a..c4bc50060 100644 --- a/main/urls.py +++ b/main/urls.py @@ -57,6 +57,7 @@ from deployments import drf_views as deployment_views from dref import views as dref_views from eap import views as eap_views +from eap.dev_views import EAPEmailPreview from flash_update import views as flash_views from lang import views as lang_views from local_units import views as local_units_views @@ -294,6 +295,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 From 37634a443d424bbdc38605ca8e22275d18c5774d Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Mon, 12 Jan 2026 16:42:12 +0545 Subject: [PATCH 49/57] fix(eap): Update default values for email environment variables from None to empty list --- main/settings.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/main/settings.py b/main/settings.py index 2ff0776d6..3a685bc86 100644 --- a/main/settings.py +++ b/main/settings.py @@ -69,13 +69,13 @@ EMAIL_PASS=(str, None), DEBUG_EMAIL=(bool, False), # This was 0/1 before # EAP-EMAILS - EMAIL_EAP_DREF_ANTICIPATORY_PILLAR=(str, None), - EMAIL_EAP_DREF_AA_GLOBAL_TEAM=(list, None), - EMAIL_EAP_AFRICA_COORDINATORS=(list, None), - EMAIL_EAP_AMERICAS_COORDINATORS=(list, None), - EMAIL_EAP_ASIA_PACIFIC_COORDINATORS=(list, None), - EMAIL_EAP_EUROPE_COORDINATORS=(list, None), - EMAIL_EAP_MENA_COORDINATORS=(list, None), + 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: From 3a2c0c2e41b3a5b8fce1f0cd9b4d56cf0c2ab3eb Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 9 Jan 2026 16:49:09 +0545 Subject: [PATCH 50/57] chore(eap): Update typings on registration and eaps (#2626) --- eap/serializers.py | 132 +++++++++++++++++++++++++-------------------- eap/views.py | 17 +++++- 2 files changed, 90 insertions(+), 59 deletions(-) diff --git a/eap/serializers.py b/eap/serializers.py index 0dc131ed8..cfca5588a 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -95,26 +95,80 @@ def update(self, instance, validated_data: dict[str, typing.Any]): 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", - "eap_registration", "total_budget", "readiness_budget", "pre_positioning_budget", "early_action_budget", "seap_timeframe", "budget_file", + "budget_file_details", "version", "is_locked", - "updated_checklist_file", + "review_checklist_file", + "updated_checklist_file_details", "created_at", "modified_at", ] @@ -123,19 +177,23 @@ class Meta: 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", - "eap_registration", "total_budget", "readiness_budget", "pre_positioning_budget", "early_action_budget", "budget_file", + "budget_file_details", "version", "is_locked", - "updated_checklist_file", + "review_checklist_file", + "updated_checklist_file_details", "created_at", "modified_at", ] @@ -199,6 +257,7 @@ class Meta: "latest_simplified_eap", "latest_full_eap", "deadline", + "summary_file", ] def create(self, validated_data: dict[str, typing.Any]): @@ -241,56 +300,6 @@ def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.An return 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 - - ALLOWED_MAP_TIMEFRAMES_VALUE = { TimeFrame.YEARS: list(YearsTimeFrameChoices.values), TimeFrame.MONTHS: list(MonthsTimeFrameChoices.values), @@ -303,6 +312,7 @@ class OperationActivitySerializer( serializers.ModelSerializer, ): id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) timeframe = serializers.ChoiceField( choices=TimeFrame.choices, required=True, @@ -340,6 +350,7 @@ class IndicatorSerializer( serializers.ModelSerializer, ): id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) class Meta: model = Indicator @@ -352,6 +363,7 @@ class PlannedOperationSerializer( 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) @@ -372,6 +384,7 @@ class EnableApproachSerializer( 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) @@ -384,16 +397,13 @@ class EnableApproachSerializer( class Meta: model = EnableApproach fields = "__all__" - read_only_fields = ( - "created_by", - "modified_by", - ) class EAPSourceInformationSerializer( serializers.ModelSerializer, ): id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) class Meta: model = SourceInformation @@ -404,6 +414,7 @@ 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: @@ -413,6 +424,7 @@ class Meta: class EAPActionSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) class Meta: model = EAPAction @@ -421,6 +433,7 @@ class Meta: class ImpactSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) class Meta: model = EAPImpact @@ -429,6 +442,7 @@ class Meta: class EAPContactSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) + previous_id = serializers.IntegerField(read_only=True) class Meta: model = EAPContact @@ -449,6 +463,7 @@ class CommonEAPFieldsSerializer(serializers.ModelSerializer): admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) budget_file = serializers.PrimaryKeyRelatedField(queryset=EAPFile.objects.all(), required=True) budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) + updated_checklist_file_details = EAPFileSerializer(source="updated_checklist_file", read_only=True) def get_fields(self): fields = super().get_fields() @@ -460,6 +475,7 @@ def get_fields(self): fields["enable_approaches"] = EnableApproachSerializer(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]: diff --git a/eap/views.py b/eap/views.py index 27bd0cb61..0fb232c09 100644 --- a/eap/views.py +++ b/eap/views.py @@ -104,7 +104,22 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: ) .prefetch_related( "partners", - "simplified_eap", + Prefetch( + "simplified_eap", + 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_eap", + queryset=FullEAP.objects.select_related( + "budget_file__created_by", + "budget_file__modified_by", + ), + ), ) ) From 4f1c6ca16c2c66b438e7927cdc1c76ba0a0395f7 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Wed, 14 Jan 2026 15:16:58 +0545 Subject: [PATCH 51/57] fix(eap): update validation for full eap - Make fulleap_technical_working_groups description nullable - Make is_worked_with_government_description nullable --- ...ng_groups_in_place_description_and_more.py | 23 +++++++++++++++++++ eap/models.py | 4 ++++ 2 files changed, 27 insertions(+) create mode 100644 eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py diff --git a/eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py b/eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py new file mode 100644 index 000000000..c9e54c511 --- /dev/null +++ b/eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.19 on 2026-01-14 09:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0015_eapregistration_deadline_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='fulleap', + name='technical_working_groups_in_place_description', + field=models.TextField(blank=True, null=True, verbose_name='Technical working groups description'), + ), + migrations.AlterField( + model_name='fulleap', + name='worked_with_government_description', + field=models.TextField(blank=True, null=True, verbose_name='Government and actors engagement description'), + ), + ] diff --git a/eap/models.py b/eap/models.py index d57959470..1dac7c8be 100644 --- a/eap/models.py +++ b/eap/models.py @@ -1210,6 +1210,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): worked_with_government_description = models.TextField( verbose_name=_("Government and actors engagement description"), + null=True, + blank=True, ) key_actors = models.ManyToManyField( @@ -1232,6 +1234,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): ) technical_working_groups_in_place_description = models.TextField( verbose_name=_("Technical working groups description"), + null=True, + blank=True, ) # RISK ANALYSIS # From 7f44dfe906df6529b61547475df6b4d5b687c746 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Fri, 16 Jan 2026 16:36:05 +0545 Subject: [PATCH 52/57] feat(admin2): add filter for multiple ids --- api/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/serializers.py b/api/serializers.py index 27e571860..f86f18e4a 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -372,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", @@ -388,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): From 9ede2e6b0c413b6a1b289c78fa39a10d066f2a44 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 14 Jan 2026 15:08:23 +0545 Subject: [PATCH 53/57] fix(eap): Squash migrations and cleanup --- .../0227_alter_export_export_type.py | 23 +- .../0228_alter_export_export_type.py | 29 - .../0229_alter_export_export_type.py | 29 - ...n_eapcontact_eapfile_eapimpact_and_more.py | 2317 +++++++++++++++++ ...eapregistration_enableapproach_and_more.py | 206 -- ...name_plannedoperations_plannedoperation.py | 17 - ...5_eapregistration_activated_at_and_more.py | 44 - ...stration_review_checklist_file_and_more.py | 24 - ..._alter_eapregistration_options_and_more.py | 78 - ...mplifiedeap_hazard_impact_file_and_more.py | 54 - .../0009_sourceinformation_and_more.py | 1025 -------- ..._eapaction_eapimpact_indicator_and_more.py | 298 --- ...fulleap_updated_checklist_file_and_more.py | 35 - ..._remove_fulleap_seap_timeframe_and_more.py | 140 - ...national_society_contact_email_and_more.py | 32 - eap/migrations/0014_eapcontact_and_more.py | 242 -- .../0015_eapregistration_deadline_and_more.py | 23 - eap/serializers.py | 27 +- eap/tasks.py | 22 +- 19 files changed, 2351 insertions(+), 2314 deletions(-) delete mode 100644 api/migrations/0228_alter_export_export_type.py delete mode 100644 api/migrations/0229_alter_export_export_type.py create mode 100644 eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py delete mode 100644 eap/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py delete mode 100644 eap/migrations/0004_rename_plannedoperations_plannedoperation.py delete mode 100644 eap/migrations/0005_eapregistration_activated_at_and_more.py delete mode 100644 eap/migrations/0006_eapregistration_review_checklist_file_and_more.py delete mode 100644 eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py delete mode 100644 eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py delete mode 100644 eap/migrations/0009_sourceinformation_and_more.py delete mode 100644 eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py delete mode 100644 eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py delete mode 100644 eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py delete mode 100644 eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py delete mode 100644 eap/migrations/0014_eapcontact_and_more.py delete mode 100644 eap/migrations/0015_eapregistration_deadline_and_more.py diff --git a/api/migrations/0227_alter_export_export_type.py b/api/migrations/0227_alter_export_export_type.py index 8fb9d801b..c3f8c75a2 100644 --- a/api/migrations/0227_alter_export_export_type.py +++ b/api/migrations/0227_alter_export_export_type.py @@ -1,18 +1,29 @@ -# Generated by Django 4.2.19 on 2025-11-18 05:22 +# 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'), + ("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-eap', 'Simplified EAP')], max_length=255, verbose_name='Export Type'), + 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/migrations/0228_alter_export_export_type.py b/api/migrations/0228_alter_export_export_type.py deleted file mode 100644 index df5dffc7f..000000000 --- a/api/migrations/0228_alter_export_export_type.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 4.2.26 on 2025-11-26 10:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0227_alter_export_export_type"), - ] - - 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-eap", "Simplified EAP"), - ("full-eap", "Full EAP"), - ], - max_length=255, - verbose_name="Export Type", - ), - ), - ] diff --git a/api/migrations/0229_alter_export_export_type.py b/api/migrations/0229_alter_export_export_type.py deleted file mode 100644 index 1e89038e5..000000000 --- a/api/migrations/0229_alter_export_export_type.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 4.2.26 on 2025-12-04 09:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0228_alter_export_export_type"), - ] - - 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/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..d9ae4e6ac --- /dev/null +++ b/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py @@ -0,0 +1,2317 @@ +# Generated by Django 4.2.26 on 2026-01-14 08:52 + +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 = [ + ("api", "0227_alter_export_export_type"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("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="EnableApproach", + 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": "Enable Approach", + "verbose_name_plural": "Enable 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_eap", + to="eap.eapregistration", + verbose_name="EAP Development Registration", + ), + ), + ( + "enable_approaches", + models.ManyToManyField( + blank=True, + related_name="simplified_eap_enable_approaches", + to="eap.enableapproach", + 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( + 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( + 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_eap", + 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", + ), + ), + ( + "enable_approaches", + models.ManyToManyField( + blank=True, + related_name="full_eap_enable_approaches", + to="eap.enableapproach", + 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="enableapproach", + name="early_action_activities", + field=models.ManyToManyField( + blank=True, + related_name="enable_approach_early_action_activities", + to="eap.operationactivity", + verbose_name="Early Action Activities", + ), + ), + migrations.AddField( + model_name="enableapproach", + name="indicators", + field=models.ManyToManyField( + blank=True, + related_name="enable_approach_indicators", + to="eap.indicator", + verbose_name="Enable Approach Indicators", + ), + ), + migrations.AddField( + model_name="enableapproach", + name="prepositioning_activities", + field=models.ManyToManyField( + blank=True, + related_name="enable_approach_prepositioning_activities", + to="eap.operationactivity", + verbose_name="Pre-positioning Activities", + ), + ), + migrations.AddField( + model_name="enableapproach", + name="readiness_activities", + field=models.ManyToManyField( + blank=True, + related_name="enable_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/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py b/eap/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py deleted file mode 100644 index a215e2649..000000000 --- a/eap/migrations/0003_eapfile_eapregistration_enableapproach_and_more.py +++ /dev/null @@ -1,206 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-07 06:33 - -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 = [ - ('api', '0226_nsdinitiativescategory_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('eap', '0002_auto_20220708_0747'), - ] - - operations = [ - 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(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', - }, - ), - 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, 'Approved'), (60, 'PFA Signed'), (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')), - ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), - ('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')), - ('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')), - ('national_society', 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)')), - ('partners', 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')), - ], - options={ - 'verbose_name': 'Development Registration EAP', - 'verbose_name_plural': 'Development Registration EAPs', - }, - ), - migrations.CreateModel( - name='EnableApproach', - 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')), - ('indicator_target', models.IntegerField(blank=True, null=True, verbose_name='Indicator Target')), - ], - options={ - 'verbose_name': 'Enable Approach', - 'verbose_name_plural': 'Enable Approaches', - }, - ), - 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')), - ], - options={ - 'verbose_name': 'Operation Activity', - 'verbose_name_plural': 'Operation Activities', - }, - ), - migrations.CreateModel( - name='PlannedOperations', - 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')), - ('early_action_activities', models.ManyToManyField(blank=True, related_name='planned_operations_early_action_activities', to='eap.operationactivity', verbose_name='Early Action Activities')), - ('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='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')), - ('seap_timeframe', models.IntegerField(help_text='A simplified EAP has a timeframe of 2 years unless early action are activated.', verbose_name='sEAP Timeframe (Years)')), - ('national_society_contact_name', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, 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')), - ('partner_ns_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS name')), - ('partner_ns_email', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS email')), - ('partner_ns_title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Partner NS title')), - ('partner_ns_phone_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Partner NS phone number')), - ('ifrc_delegation_focal_point_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='IFRC delegation focal point name')), - ('ifrc_delegation_focal_point_email', models.CharField(blank=True, max_length=255, null=True, 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(blank=True, max_length=255, null=True, verbose_name='IFRC head of delegation name')), - ('ifrc_head_of_delegation_email', models.CharField(blank=True, max_length=255, null=True, 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')), - ('prioritized_hazard_and_impact', models.TextField(blank=True, null=True, verbose_name='Prioritized Hazard and its historical impact.')), - ('risks_selected_protocols', models.TextField(blank=True, null=True, verbose_name='Risk selected for the protocols.')), - ('selected_early_actions', models.TextField(blank=True, null=True, verbose_name='Selected Early Actions')), - ('overall_objective_intervention', models.TextField(blank=True, help_text='Provide an objective statement that describe the main of the intervention.', null=True, verbose_name='Overall objective of the intervention')), - ('potential_geographical_high_risk_areas', models.TextField(blank=True, null=True, verbose_name='Potential geographical high-risk areas')), - ('people_targeted', models.IntegerField(blank=True, null=True, verbose_name='People Targeted.')), - ('assisted_through_operation', models.TextField(blank=True, null=True, 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_time', models.IntegerField(blank=True, null=True, verbose_name='sEAP Lead Time (Hours)')), - ('operational_timeframe', models.IntegerField(blank=True, null=True, verbose_name='Operational Timeframe (Months)')), - ('trigger_threshold_justification', models.TextField(blank=True, help_text='Explain how the trigger were set and provide information', null=True, verbose_name='Trigger Threshold Justification')), - ('next_step_towards_full_eap', models.TextField(verbose_name='Next Steps towards Full EAP')), - ('early_action_capability', models.TextField(blank=True, help_text='Assumptions or minimum conditions needed to deliver the early actions.', null=True, verbose_name='Experience or Capacity to implement Early Action.')), - ('rcrc_movement_involvement', models.TextField(blank=True, help_text='RCRC Movement partners, Governmental/other agencies consulted/involved.', null=True, verbose_name='RCRC Movement Involvement.')), - ('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)')), - ('budget_file', main.fields.SecureFileField(blank=True, null=True, upload_to='eap/simplified_eap/budget_files/', verbose_name='Budget File')), - ('admin2', models.ManyToManyField(blank=True, to='api.admin2', verbose_name='admin2')), - ('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cover_image_simplified_eap', 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.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='simplified_eap', to='eap.eapregistration', verbose_name='EAP Development Registration')), - ('enable_approaches', models.ManyToManyField(blank=True, related_name='simplified_eap_enable_approaches', to='eap.enableapproach', verbose_name='Enabling Approaches')), - ('hazard_impact_file', models.ManyToManyField(blank=True, related_name='simplified_eap_hazard_impact_files', to='eap.eapfile', verbose_name='Hazard Impact 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')), - ('planned_operations', models.ManyToManyField(blank=True, to='eap.plannedoperations', verbose_name='Planned Operations')), - ('risk_selected_protocols_file', models.ManyToManyField(blank=True, related_name='simplified_eap_risk_selected_protocols_files', to='eap.eapfile', verbose_name='Risk Selected Protocols Files')), - ('selected_early_actions_file', models.ManyToManyField(blank=True, related_name='simplified_eap_selected_early_actions_files', to='eap.eapfile', verbose_name='Selected Early Actions Files')), - ], - options={ - 'verbose_name': 'Simplified EAP', - 'verbose_name_plural': 'Simplified EAPs', - }, - ), - migrations.AddField( - model_name='enableapproach', - name='early_action_activities', - field=models.ManyToManyField(blank=True, related_name='enable_approach_early_action_activities', to='eap.operationactivity', verbose_name='Early Action Activities'), - ), - migrations.AddField( - model_name='enableapproach', - name='prepositioning_activities', - field=models.ManyToManyField(blank=True, related_name='enable_approach_prepositioning_activities', to='eap.operationactivity', verbose_name='Pre-positioning Activities'), - ), - migrations.AddField( - model_name='enableapproach', - name='readiness_activities', - field=models.ManyToManyField(blank=True, related_name='enable_approach_readiness_activities', to='eap.operationactivity', verbose_name='Readiness Activities'), - ), - ] diff --git a/eap/migrations/0004_rename_plannedoperations_plannedoperation.py b/eap/migrations/0004_rename_plannedoperations_plannedoperation.py deleted file mode 100644 index 5fb6e477c..000000000 --- a/eap/migrations/0004_rename_plannedoperations_plannedoperation.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-11 06:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('eap', '0003_eapfile_eapregistration_enableapproach_and_more'), - ] - - operations = [ - migrations.RenameModel( - old_name='PlannedOperations', - new_name='PlannedOperation', - ), - ] diff --git a/eap/migrations/0005_eapregistration_activated_at_and_more.py b/eap/migrations/0005_eapregistration_activated_at_and_more.py deleted file mode 100644 index 70fa1ae9e..000000000 --- a/eap/migrations/0005_eapregistration_activated_at_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-13 10:51 - -from django.db import migrations, models -import main.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('eap', '0004_rename_plannedoperations_plannedoperation'), - ] - - operations = [ - migrations.AddField( - model_name='eapregistration', - name='activated_at', - field=models.DateTimeField(blank=True, help_text='Timestamp when the EAP was activated.', null=True, verbose_name='activated at'), - ), - migrations.AddField( - model_name='eapregistration', - name='approved_at', - field=models.DateTimeField(blank=True, help_text='Timestamp when the EAP was approved.', null=True, verbose_name='approved at'), - ), - migrations.AddField( - model_name='eapregistration', - name='pfa_signed_at', - field=models.DateTimeField(blank=True, help_text='Timestamp when the PFA was signed.', null=True, verbose_name='PFA signed at'), - ), - migrations.AddField( - model_name='eapregistration', - name='technically_validated_at', - field=models.DateTimeField(blank=True, help_text='Timestamp when the EAP was technically validated.', null=True, verbose_name='technically validated at'), - ), - migrations.AddField( - model_name='eapregistration', - name='validated_budget_file', - field=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'), - ), - migrations.AlterField( - model_name='eapfile', - name='file', - field=main.fields.SecureFileField(help_text='Upload EAP related file.', upload_to='eap/files/', verbose_name='file'), - ), - ] diff --git a/eap/migrations/0006_eapregistration_review_checklist_file_and_more.py b/eap/migrations/0006_eapregistration_review_checklist_file_and_more.py deleted file mode 100644 index beae0058a..000000000 --- a/eap/migrations/0006_eapregistration_review_checklist_file_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-14 10:27 - -from django.db import migrations -import main.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('eap', '0005_eapregistration_activated_at_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='eapregistration', - name='review_checklist_file', - field=main.fields.SecureFileField(blank=True, null=True, upload_to='eap/files/', verbose_name='Review Checklist File'), - ), - migrations.AddField( - model_name='simplifiedeap', - name='updated_checklist_file', - field=main.fields.SecureFileField(blank=True, null=True, upload_to='eap/files/', verbose_name='Updated Checklist File'), - ), - ] diff --git a/eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py b/eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py deleted file mode 100644 index 4256bc9ed..000000000 --- a/eap/migrations/0007_alter_eapfile_options_alter_eapregistration_options_and_more.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-20 07:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0006_eapregistration_review_checklist_file_and_more"), - ] - - operations = [ - migrations.AlterModelOptions( - name="eapfile", - options={ - "ordering": ["-id"], - "verbose_name": "eap file", - "verbose_name_plural": "eap files", - }, - ), - migrations.AlterModelOptions( - name="eapregistration", - options={ - "ordering": ["-id"], - "verbose_name": "Development Registration EAP", - "verbose_name_plural": "Development Registration EAPs", - }, - ), - migrations.AlterModelOptions( - name="simplifiedeap", - options={ - "ordering": ["-id"], - "verbose_name": "Simplified EAP", - "verbose_name_plural": "Simplified EAPs", - }, - ), - migrations.AddField( - model_name="simplifiedeap", - name="is_locked", - field=models.BooleanField( - default=False, - help_text="Indicates whether the Simplified EAP is locked for editing.", - verbose_name="Is Locked?", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="parent", - field=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", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="version", - field=models.IntegerField( - default=1, - help_text="Version identifier for the Simplified EAP.", - verbose_name="Version", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="eap_registration", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="simplified_eap", - to="eap.eapregistration", - verbose_name="EAP Development Registration", - ), - ), - ] diff --git a/eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py b/eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py deleted file mode 100644 index d5ca984b5..000000000 --- a/eap/migrations/0008_remove_simplifiedeap_hazard_impact_file_and_more.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-25 10:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0007_alter_eapfile_options_alter_eapregistration_options_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="simplifiedeap", - name="hazard_impact_file", - ), - migrations.RemoveField( - model_name="simplifiedeap", - name="risk_selected_protocols_file", - ), - migrations.RemoveField( - model_name="simplifiedeap", - name="selected_early_actions_file", - ), - migrations.AddField( - model_name="simplifiedeap", - name="hazard_impact_images", - field=models.ManyToManyField( - blank=True, - related_name="simplified_eap_hazard_impact_images", - to="eap.eapfile", - verbose_name="Hazard Impact Images", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="risk_selected_protocols_images", - field=models.ManyToManyField( - blank=True, - related_name="simplified_eap_risk_selected_protocols_images", - to="eap.eapfile", - verbose_name="Risk Selected Protocols Images", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="selected_early_actions_images", - field=models.ManyToManyField( - blank=True, - related_name="simplified_eap_selected_early_actions_images", - to="eap.eapfile", - verbose_name="Selected Early Actions Images", - ), - ), - ] diff --git a/eap/migrations/0009_sourceinformation_and_more.py b/eap/migrations/0009_sourceinformation_and_more.py deleted file mode 100644 index ae0c43d63..000000000 --- a/eap/migrations/0009_sourceinformation_and_more.py +++ /dev/null @@ -1,1025 +0,0 @@ -# Generated by Django 4.2.19 on 2025-11-27 05:18 - -from django.conf import settings -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", "0228_alter_export_export_type"), - ("eap", "0008_remove_simplifiedeap_hazard_impact_file_and_more"), - ] - - 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"), - ), - ], - options={ - "verbose_name": "Source of Information", - "verbose_name_plural": "Source of Information", - }, - ), - migrations.AddField( - model_name="simplifiedeap", - name="operational_timeframe_unit", - field=models.IntegerField( - choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], - default=20, - verbose_name="Operational Timeframe Unit", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="seap_lead_timeframe_unit", - field=models.IntegerField( - choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], - default=20, - verbose_name="sEAP Lead Timeframe Unit", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="admin2", - field=models.ManyToManyField( - blank=True, related_name="+", to="api.admin2", verbose_name="admin" - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="budget_file", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="eap.eapfile", - verbose_name="Budget File", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="cover_image", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="eap.eapfile", - verbose_name="cover image", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="operational_timeframe", - field=models.IntegerField(verbose_name="Operational Time"), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="people_targeted", - field=models.IntegerField(verbose_name="People Targeted."), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="seap_lead_time", - field=models.IntegerField(verbose_name="sEAP Lead Time"), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="seap_timeframe", - field=models.IntegerField( - help_text="Timeframe of the EAP in years.", - verbose_name="Timeframe (Years) of the EAP", - ), - ), - 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", - ), - ), - ( - "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"), - ), - ( - "seap_timeframe", - models.IntegerField( - help_text="Timeframe of the EAP in years.", - verbose_name="Timeframe (Years) of the EAP", - ), - ), - ( - "people_targeted", - models.IntegerField(verbose_name="People Targeted."), - ), - ( - "national_society_contact_name", - models.CharField( - blank=True, - max_length=255, - null=True, - 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( - blank=True, - max_length=255, - null=True, - 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", - ), - ), - ( - "partner_ns_name", - models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="Partner NS name", - ), - ), - ( - "partner_ns_email", - models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="Partner NS email", - ), - ), - ( - "partner_ns_title", - models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="Partner NS title", - ), - ), - ( - "partner_ns_phone_number", - models.CharField( - blank=True, - max_length=100, - null=True, - verbose_name="Partner NS phone number", - ), - ), - ( - "ifrc_delegation_focal_point_name", - models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="IFRC delegation focal point name", - ), - ), - ( - "ifrc_delegation_focal_point_email", - models.CharField( - blank=True, - max_length=255, - null=True, - 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( - blank=True, - max_length=255, - null=True, - verbose_name="IFRC head of delegation name", - ), - ), - ( - "ifrc_head_of_delegation_email", - models.CharField( - blank=True, - max_length=255, - null=True, - 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", - ), - ), - ( - "updated_checklist_file", - main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/", - verbose_name="Updated 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)"), - ), - ( - "is_worked_with_government", - models.BooleanField( - default=False, - verbose_name="Has Worked with government or other relevant actors.", - ), - ), - ( - "worked_with_government_description", - models.TextField( - 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( - 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", - ), - ), - ( - "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" - ), - ), - ( - "selection_area", - models.TextField( - help_text="Add description for the selection of the areas.", - verbose_name="Areas selection rationale", - ), - ), - ( - "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_eap", - 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", - ), - ), - ( - "enable_approaches", - models.ManyToManyField( - blank=True, - related_name="full_eap_enable_approaches", - to="eap.enableapproach", - 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", - ), - ), - ( - "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", - ), - ), - ( - "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", - ), - ), - ( - "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 Information", - ), - ), - ], - options={ - "verbose_name": "Full EAP", - "verbose_name_plural": "Full EAPs", - "ordering": ["-id"], - }, - ), - ] diff --git a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py b/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py deleted file mode 100644 index ce96d5f83..000000000 --- a/eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py +++ /dev/null @@ -1,298 +0,0 @@ -# Generated by Django 4.2.26 on 2025-12-10 08:52 - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0009_sourceinformation_and_more"), - ] - - 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"), - ), - ], - options={ - "verbose_name": "Early Action", - "verbose_name_plural": "Early Actions", - }, - ), - 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")), - ], - options={ - "verbose_name": " Impact", - "verbose_name_plural": "Expected Impacts", - }, - ), - 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")), - ], - options={ - "verbose_name": "Indicator", - "verbose_name_plural": "Indicators", - }, - ), - migrations.RemoveField( - model_name="eapregistration", - name="pfa_signed_at", - ), - migrations.RemoveField( - model_name="enableapproach", - name="indicator_target", - ), - 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="pending_pfa_at", - field=models.DateTimeField( - blank=True, - help_text="Timestamp when the EAP was marked as pending PFA.", - null=True, - verbose_name="pending pfa at", - ), - ), - migrations.AddField( - model_name="fulleap", - name="expected_submission_time", - field=models.DateField( - default=django.utils.timezone.now, - help_text="Include the propose time of submission, accounting for the time it will take to deliver the application.", - verbose_name="Expected submission time", - ), - preserve_default=False, - ), - migrations.AddField( - model_name="fulleap", - name="forecast_table_file", - field=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", - ), - ), - migrations.AddField( - model_name="fulleap", - name="lead_time", - field=models.IntegerField(default=1, verbose_name="Lead Time"), - preserve_default=False, - ), - migrations.AddField( - model_name="fulleap", - name="objective", - field=models.TextField( - default="default", - help_text="Provide an objective statement that describe the main goal of intervention.", - verbose_name="Overall Objective of the EAP.", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="eapregistration", - name="status", - field=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", - ), - ), - migrations.AlterField( - model_name="fulleap", - name="trigger_statement_source_of_information", - field=models.ManyToManyField( - blank=True, - related_name="trigger_statement_source_of_information", - to="eap.sourceinformation", - verbose_name="Trigger Statement Source of Forecast", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="assisted_through_operation", - field=models.TextField(verbose_name="Assisted through the operation"), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="early_action_capability", - field=models.TextField( - help_text="Assumptions or minimum conditions needed to deliver the early actions.", - verbose_name="Experience or Capacity to implement Early Action.", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="overall_objective_intervention", - field=models.TextField( - help_text="Provide an objective statement that describe the main of the intervention.", - verbose_name="Overall objective of the intervention", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="potential_geographical_high_risk_areas", - field=models.TextField( - verbose_name="Potential geographical high-risk areas" - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="prioritized_hazard_and_impact", - field=models.TextField( - verbose_name="Prioritized Hazard and its historical impact." - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="rcrc_movement_involvement", - field=models.TextField( - help_text="RCRC Movement partners, Governmental/other agencies consulted/involved.", - verbose_name="RCRC Movement Involvement.", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="risks_selected_protocols", - field=models.TextField(verbose_name="Risk selected for the protocols."), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="selected_early_actions", - field=models.TextField(verbose_name="Selected Early Actions"), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="trigger_threshold_justification", - field=models.TextField( - help_text="Explain how the trigger were set and provide information", - verbose_name="Trigger Threshold Justification", - ), - ), - migrations.AddConstraint( - model_name="fulleap", - constraint=models.UniqueConstraint( - fields=("eap_registration", "version"), name="unique_full_eap_version" - ), - ), - migrations.AddConstraint( - model_name="simplifiedeap", - constraint=models.UniqueConstraint( - fields=("eap_registration", "version"), - name="unique_simplified_eap_version", - ), - ), - migrations.AddField( - model_name="enableapproach", - name="indicators", - field=models.ManyToManyField( - blank=True, - related_name="enable_approach_indicators", - to="eap.indicator", - verbose_name="Enable Approach Indicators", - ), - ), - migrations.AddField( - model_name="fulleap", - name="early_actions", - field=models.ManyToManyField( - related_name="full_eap_early_actions", - to="eap.eapaction", - verbose_name="Early Actions", - ), - ), - migrations.AddField( - model_name="fulleap", - name="prioritized_impacts", - field=models.ManyToManyField( - related_name="full_eap_prioritized_impacts", - to="eap.eapimpact", - verbose_name="Prioritized impacts", - ), - ), - migrations.AddField( - model_name="plannedoperation", - name="indicators", - field=models.ManyToManyField( - blank=True, - related_name="planned_operation_indicators", - to="eap.indicator", - verbose_name="Operation Indicators", - ), - ), - ] diff --git a/eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py b/eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py deleted file mode 100644 index 1bc9310dd..000000000 --- a/eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.2.26 on 2025-12-15 06:33 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0010_eapaction_eapimpact_indicator_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="fulleap", - name="updated_checklist_file", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="eap.eapfile", - verbose_name="Updated Review Checklist File", - ), - ), - migrations.AlterField( - model_name="simplifiedeap", - name="updated_checklist_file", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="eap.eapfile", - verbose_name="Updated Review Checklist File", - ), - ), - ] diff --git a/eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py b/eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py deleted file mode 100644 index 57ff54f65..000000000 --- a/eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py +++ /dev/null @@ -1,140 +0,0 @@ -# Generated by Django 4.2.26 on 2025-12-19 04:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0011_alter_fulleap_updated_checklist_file_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="fulleap", - name="seap_timeframe", - ), - migrations.RemoveField( - model_name="fulleap", - name="selection_area", - ), - migrations.AlterField( - model_name="fulleap", - name="ifrc_delegation_focal_point_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="IFRC delegation focal point email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="fulleap", - name="ifrc_delegation_focal_point_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="IFRC delegation focal point name", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="fulleap", - name="ifrc_head_of_delegation_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="IFRC head of delegation email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="fulleap", - name="ifrc_head_of_delegation_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="IFRC head of delegation name", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="fulleap", - name="national_society_contact_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="national society contact email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="fulleap", - name="national_society_contact_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="national society contact name", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="ifrc_delegation_focal_point_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="IFRC delegation focal point email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="ifrc_delegation_focal_point_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="IFRC delegation focal point name", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="ifrc_head_of_delegation_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="IFRC head of delegation email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="ifrc_head_of_delegation_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="IFRC head of delegation name", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="national_society_contact_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="national society contact email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="simplifiedeap", - name="national_society_contact_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="national society contact name", - ), - preserve_default=False, - ), - ] diff --git a/eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py b/eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py deleted file mode 100644 index 9681cf2ca..000000000 --- a/eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.2.26 on 2025-12-19 07:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0012_remove_fulleap_seap_timeframe_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="eapregistration", - name="national_society_contact_email", - field=models.CharField( - default="test@gmail.com", - max_length=255, - verbose_name="national society contact email", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="eapregistration", - name="national_society_contact_name", - field=models.CharField( - default="test", - max_length=255, - verbose_name="national society contact name", - ), - preserve_default=False, - ), - ] diff --git a/eap/migrations/0014_eapcontact_and_more.py b/eap/migrations/0014_eapcontact_and_more.py deleted file mode 100644 index 1e85e7dd5..000000000 --- a/eap/migrations/0014_eapcontact_and_more.py +++ /dev/null @@ -1,242 +0,0 @@ -# Generated by Django 4.2.26 on 2026-01-07 08:22 - -from django.db import migrations, models -import main.fields - - -class Migration(migrations.Migration): - dependencies = [ - ("eap", "0013_alter_eapregistration_national_society_contact_email_and_more"), - ] - - operations = [ - 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.RemoveField( - model_name="eapregistration", - name="review_checklist_file", - ), - migrations.RemoveField( - model_name="fulleap", - name="partner_ns_email", - ), - migrations.RemoveField( - model_name="fulleap", - name="partner_ns_name", - ), - migrations.RemoveField( - model_name="fulleap", - name="partner_ns_phone_number", - ), - migrations.RemoveField( - model_name="fulleap", - name="partner_ns_title", - ), - migrations.RemoveField( - model_name="simplifiedeap", - name="partner_ns_email", - ), - migrations.RemoveField( - model_name="simplifiedeap", - name="partner_ns_name", - ), - migrations.RemoveField( - model_name="simplifiedeap", - name="partner_ns_phone_number", - ), - migrations.RemoveField( - model_name="simplifiedeap", - name="partner_ns_title", - ), - migrations.AddField( - model_name="eapaction", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="eapimpact", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="eapregistration", - name="summary_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/", - verbose_name="EAP Summary PDF", - ), - ), - migrations.AddField( - model_name="enableapproach", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="fulleap", - name="diff_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/", - verbose_name="EAP Diff PDF file", - ), - ), - migrations.AddField( - model_name="fulleap", - name="export_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/exports/", - verbose_name="EAP Export File", - ), - ), - migrations.AddField( - model_name="fulleap", - name="review_checklist_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/", - verbose_name="Review Checklist File", - ), - ), - migrations.AddField( - model_name="indicator", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="keyactor", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="operationactivity", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="plannedoperation", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="diff_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/", - verbose_name="EAP Diff PDF file", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="export_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/exports/", - verbose_name="EAP Export File", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="review_checklist_file", - field=main.fields.SecureFileField( - blank=True, - null=True, - upload_to="eap/files/", - verbose_name="Review Checklist File", - ), - ), - migrations.AddField( - model_name="sourceinformation", - name="previous_id", - field=models.PositiveIntegerField( - blank=True, null=True, verbose_name="Previous ID" - ), - ), - migrations.AddField( - model_name="fulleap", - name="partner_contacts", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="eap.eapcontact", - verbose_name="Partner NS Contacts", - ), - ), - migrations.AddField( - model_name="simplifiedeap", - name="partner_contacts", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="eap.eapcontact", - verbose_name="Partner NS Contacts", - ), - ), - ] diff --git a/eap/migrations/0015_eapregistration_deadline_and_more.py b/eap/migrations/0015_eapregistration_deadline_and_more.py deleted file mode 100644 index 5da8e7b97..000000000 --- a/eap/migrations/0015_eapregistration_deadline_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.26 on 2026-01-08 07:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('eap', '0014_eapcontact_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='eapregistration', - name='deadline', - field=models.DateField(blank=True, help_text='Date by which the EAP submission must be completed.', null=True, verbose_name='deadline'), - ), - migrations.AddField( - model_name='eapregistration', - name='deadline_remainder_sent_at', - field=models.DateTimeField(blank=True, help_text='Timestamp when the deadline reminder email was sent.', null=True, verbose_name='deadline reminder email sent at'), - ), - ] diff --git a/eap/serializers.py b/eap/serializers.py index cfca5588a..a1f07f1ed 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -598,8 +598,8 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: 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, + gettext("Cannot update while EAP Application is in %s.") + % EAPRegistration.Status(eap_registration.get_status_enum).label ) # NOTE: Cannot update locked Simplified EAP @@ -1007,9 +1007,8 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any if old_status == new_status: return updated_instance + # NOTE: Email Notifications eap_registration_id = updated_instance.id - assert updated_instance.get_eap_type_enum is not None, "EAP type must not be None" - if updated_instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: eap_count = SimplifiedEAP.objects.filter(eap_registration=updated_instance).count() else: @@ -1021,10 +1020,10 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any ): transaction.on_commit(lambda: send_new_eap_submission_email.delay(eap_registration_id)) - elif (old_status, new_status) == ( - EAPRegistration.Status.UNDER_REVIEW, - EAPRegistration.Status.NS_ADDRESSING_COMMENTS, - ): + 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 @@ -1044,6 +1043,7 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any 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. @@ -1073,17 +1073,6 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any EAPRegistration.Status.UNDER_REVIEW, ): transaction.on_commit(lambda: send_eap_resubmission_email.delay(eap_registration_id)) - elif (old_status, new_status) == ( - EAPRegistration.Status.TECHNICALLY_VALIDATED, - EAPRegistration.Status.NS_ADDRESSING_COMMENTS, - ): - updated_instance.deadline = timezone.now().date() + timedelta(days=30) - updated_instance.save( - update_fields=[ - "deadline", - ] - ) - transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) elif (old_status, new_status) == ( EAPRegistration.Status.UNDER_REVIEW, diff --git a/eap/tasks.py b/eap/tasks.py index 2577e33a1..21aff9b27 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -164,7 +164,6 @@ def generate_export_eap_pdf(eap_registration_id, version): @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 @@ -199,8 +198,7 @@ def send_new_eap_registration_email(eap_registration_id: int): mailtype=email_type, cc_recipients=cc_recipients, ) - - return email_context + return True @shared_task @@ -251,13 +249,11 @@ def send_new_eap_submission_email(eap_registration_id: int): mailtype=email_type, cc_recipients=cc_recipients, ) - - return email_context + 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 @@ -304,7 +300,7 @@ def send_feedback_email(eap_registration_id: int): cc_recipients=cc_recipients, ) - return email_context + return True @shared_task @@ -361,7 +357,7 @@ def send_eap_resubmission_email(eap_registration_id: int): cc_recipients=cc_recipients, ) - return email_context + return True @shared_task @@ -425,7 +421,7 @@ def send_feedback_email_for_resubmitted_eap(eap_registration_id: int): cc_recipients=cc_recipients, ) - return email_context + return True @shared_task @@ -470,7 +466,7 @@ def send_technical_validation_email(eap_registration_id: int): mailtype=email_type, cc_recipients=cc_recipients, ) - return email_context + return True @shared_task @@ -520,7 +516,7 @@ def send_pending_pfa_email(eap_registration_id: int): mailtype=email_type, cc_recipients=cc_recipients, ) - return email_context + return True @shared_task @@ -564,7 +560,7 @@ def send_approved_email(eap_registration_id: int): mailtype=email_type, cc_recipients=cc_recipients, ) - return email_context + return True @shared_task @@ -612,4 +608,4 @@ def send_deadline_reminder_email(eap_registration_id: int): instance.deadline_remainder_sent_at = timezone.now() instance.save(update_fields=["deadline_remainder_sent_at"]) - return email_context + return True From 30e2f9bbc356ed7c62686d31aa6dabc121735051 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 16 Jan 2026 17:51:09 +0545 Subject: [PATCH 54/57] chore(eap): update global file export url, test cases - Change eap global export file url --- api/utils.py | 19 +- eap/admin.py | 4 +- eap/enums.py | 2 +- eap/factories.py | 12 +- ...n_eapcontact_eapfile_eapimpact_and_more.py | 52 ++--- ...ng_groups_in_place_description_and_more.py | 23 -- eap/models.py | 44 ++-- eap/serializers.py | 52 +++-- eap/test_views.py | 201 ++++++++++-------- eap/views.py | 14 +- 10 files changed, 223 insertions(+), 200 deletions(-) delete mode 100644 eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py diff --git a/api/utils.py b/api/utils.py index 4b89da6f1..9d8386d74 100644 --- a/api/utils.py +++ b/api/utils.py @@ -160,8 +160,8 @@ class CountryValidator(TypedDict): def generate_eap_export_url( registration_id: int, - diff: bool = False, version: Optional[int] = None, + diff: bool = False, summary: bool = False, ) -> str: """ @@ -169,7 +169,22 @@ def generate_eap_export_url( """ 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}" @@ -177,6 +192,4 @@ def generate_eap_export_url( if diff: url += "&diff=true" if version else "?diff=true" - if summary: - url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{registration_id}/summary/export/" return url diff --git a/eap/admin.py b/eap/admin.py index d7bb6fc2a..e6e7a107e 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -99,7 +99,7 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): "risk_selected_protocols_images", "selected_early_actions_images", "planned_operations", - "enable_approaches", + "enabling_approaches", "parent", "is_locked", "version", @@ -187,7 +187,7 @@ class FullEAPAdmin(admin.ModelAdmin): "partner_contacts", "cover_image", "planned_operations", - "enable_approaches", + "enabling_approaches", "planned_operations", "hazard_selection_images", "theory_of_change_table_file", diff --git a/eap/enums.py b/eap/enums.py index ee9f78248..0cdf730fe 100644 --- a/eap/enums.py +++ b/eap/enums.py @@ -9,5 +9,5 @@ "months_timeframe_value": models.MonthsTimeFrameChoices, "days_timeframe_value": models.DaysTimeFrameChoices, "hours_timeframe_value": models.HoursTimeFrameChoices, - "approach": models.EnableApproach.Approach, + "approach": models.EnablingApproach.Approach, } diff --git a/eap/factories.py b/eap/factories.py index 219d705ee..3dbe3fb1e 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -10,7 +10,7 @@ EAPRegistration, EAPStatus, EAPType, - EnableApproach, + EnablingApproach, FullEAP, KeyActor, OperationActivity, @@ -96,13 +96,13 @@ class Meta: ifrc_head_of_delegation_email = factory.LazyAttribute(lambda obj: f"{obj.ifrc_head_of_delegation_name.lower()}@example.com") @factory.post_generation - def enable_approaches(self, create, extracted, **kwargs): + def enabling_approaches(self, create, extracted, **kwargs): if not create: return if extracted: for approach in extracted: - self.enable_approaches.add(approach) + self.enabling_approaches.add(approach) @factory.post_generation def planned_operations(self, create, extracted, **kwargs): @@ -122,11 +122,11 @@ class Meta: timeframe = fuzzy.FuzzyChoice(TimeFrame) -class EnableApproachFactory(factory.django.DjangoModelFactory): +class EnablingApproachFactory(factory.django.DjangoModelFactory): class Meta: - model = EnableApproach + model = EnablingApproach - approach = fuzzy.FuzzyChoice(EnableApproach.Approach) + approach = fuzzy.FuzzyChoice(EnablingApproach.Approach) budget_per_approach = fuzzy.FuzzyInteger(1000, 1000000) ap_code = fuzzy.FuzzyInteger(100, 999) diff --git a/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py b/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py index d9ae4e6ac..c0cd6d48b 100644 --- a/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py +++ b/eap/migrations/0003_eapaction_eapcontact_eapfile_eapimpact_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2026-01-14 08:52 +# Generated by Django 4.2.26 on 2026-01-16 06:27 from django.conf import settings import django.contrib.postgres.fields @@ -9,8 +9,8 @@ class Migration(migrations.Migration): dependencies = [ - ("api", "0227_alter_export_export_type"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api", "0227_alter_export_export_type"), ("eap", "0002_auto_20220708_0747"), ] @@ -436,7 +436,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="EnableApproach", + name="EnablingApproach", fields=[ ( "id", @@ -474,8 +474,8 @@ class Migration(migrations.Migration): ), ], options={ - "verbose_name": "Enable Approach", - "verbose_name_plural": "Enable Approaches", + "verbose_name": "Enabling Approach", + "verbose_name_plural": "Enabling Approaches", }, ), migrations.CreateModel( @@ -1181,17 +1181,17 @@ class Migration(migrations.Migration): "eap_registration", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="simplified_eap", + related_name="simplified_eaps", to="eap.eapregistration", verbose_name="EAP Development Registration", ), ), ( - "enable_approaches", + "enabling_approaches", models.ManyToManyField( blank=True, - related_name="simplified_eap_enable_approaches", - to="eap.enableapproach", + related_name="simplified_eap_enabling_approaches", + to="eap.enablingapproach", verbose_name="Enabling Approaches", ), ), @@ -1679,7 +1679,9 @@ class Migration(migrations.Migration): ( "worked_with_government_description", models.TextField( - verbose_name="Government and actors engagement description" + blank=True, + null=True, + verbose_name="Government and actors engagement description", ), ), ( @@ -1702,7 +1704,9 @@ class Migration(migrations.Migration): ( "technical_working_groups_in_place_description", models.TextField( - verbose_name="Technical working groups description" + blank=True, + null=True, + verbose_name="Technical working groups description", ), ), ( @@ -1945,7 +1949,7 @@ class Migration(migrations.Migration): "eap_registration", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="full_eap", + related_name="full_eaps", to="eap.eapregistration", verbose_name="EAP Development Registration", ), @@ -1977,11 +1981,11 @@ class Migration(migrations.Migration): ), ), ( - "enable_approaches", + "enabling_approaches", models.ManyToManyField( blank=True, - related_name="full_eap_enable_approaches", - to="eap.enableapproach", + related_name="full_eap_enabling_approaches", + to="eap.enablingapproach", verbose_name="Enabling approaches", ), ), @@ -2206,41 +2210,41 @@ class Migration(migrations.Migration): }, ), migrations.AddField( - model_name="enableapproach", + model_name="enablingapproach", name="early_action_activities", field=models.ManyToManyField( blank=True, - related_name="enable_approach_early_action_activities", + related_name="enabling_approach_early_action_activities", to="eap.operationactivity", verbose_name="Early Action Activities", ), ), migrations.AddField( - model_name="enableapproach", + model_name="enablingapproach", name="indicators", field=models.ManyToManyField( blank=True, - related_name="enable_approach_indicators", + related_name="enabling_approach_indicators", to="eap.indicator", - verbose_name="Enable Approach Indicators", + verbose_name="Enabling Approach Indicators", ), ), migrations.AddField( - model_name="enableapproach", + model_name="enablingapproach", name="prepositioning_activities", field=models.ManyToManyField( blank=True, - related_name="enable_approach_prepositioning_activities", + related_name="enabling_approach_prepositioning_activities", to="eap.operationactivity", verbose_name="Pre-positioning Activities", ), ), migrations.AddField( - model_name="enableapproach", + model_name="enablingapproach", name="readiness_activities", field=models.ManyToManyField( blank=True, - related_name="enable_approach_readiness_activities", + related_name="enabling_approach_readiness_activities", to="eap.operationactivity", verbose_name="Readiness Activities", ), diff --git a/eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py b/eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py deleted file mode 100644 index c9e54c511..000000000 --- a/eap/migrations/0016_alter_fulleap_technical_working_groups_in_place_description_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.19 on 2026-01-14 09:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('eap', '0015_eapregistration_deadline_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='fulleap', - name='technical_working_groups_in_place_description', - field=models.TextField(blank=True, null=True, verbose_name='Technical working groups description'), - ), - migrations.AlterField( - model_name='fulleap', - name='worked_with_government_description', - field=models.TextField(blank=True, null=True, verbose_name='Government and actors engagement description'), - ), - ] diff --git a/eap/models.py b/eap/models.py index 1dac7c8be..4656466cf 100644 --- a/eap/models.py +++ b/eap/models.py @@ -434,7 +434,7 @@ def __str__(self): return f"Planned Operation - {self.get_sector_display()}" -class EnableApproach(models.Model): +class EnablingApproach(models.Model): class Approach(models.IntegerChoices): SECRETARIAT_SERVICES = 10, _("Secretariat Services") NATIONAL_SOCIETY_STRENGTHENING = 20, _("National Society Strengthening") @@ -447,37 +447,37 @@ class Approach(models.IntegerChoices): indicators = models.ManyToManyField( Indicator, - verbose_name=_("Enable Approach Indicators"), + verbose_name=_("Enabling Approach Indicators"), blank=True, - related_name="enable_approach_indicators", + related_name="enabling_approach_indicators", ) # Activities readiness_activities = models.ManyToManyField( OperationActivity, verbose_name=_("Readiness Activities"), - related_name="enable_approach_readiness_activities", + related_name="enabling_approach_readiness_activities", blank=True, ) prepositioning_activities = models.ManyToManyField( OperationActivity, verbose_name=_("Pre-positioning Activities"), - related_name="enable_approach_prepositioning_activities", + related_name="enabling_approach_prepositioning_activities", blank=True, ) early_action_activities = models.ManyToManyField( OperationActivity, verbose_name=_("Early Action Activities"), - related_name="enable_approach_early_action_activities", + related_name="enabling_approach_early_action_activities", blank=True, ) class Meta: - verbose_name = _("Enable Approach") - verbose_name_plural = _("Enable Approaches") + verbose_name = _("Enabling Approach") + verbose_name_plural = _("Enabling Approaches") def __str__(self): - return f"Enable Approach - {self.get_approach_display()}" + return f"Enabling Approach - {self.get_approach_display()}" class SourceInformation(models.Model): @@ -742,8 +742,8 @@ class EAPRegistration(EAPBaseModel): national_society_id: int country_id: int disaster_type_id: int - simplified_eap: models.Manager["SimplifiedEAP"] - full_eap: models.Manager["FullEAP"] + simplified_eaps: models.Manager["SimplifiedEAP"] + full_eaps: models.Manager["FullEAP"] class Meta: verbose_name = _("Development Registration EAP") @@ -757,7 +757,7 @@ def __str__(self): @property def has_eap_application(self) -> bool: """Check if the EAP Registration has an associated EAP application.""" - return self.simplified_eap.exists() or self.full_eap.exists() + return self.simplified_eaps.exists() or self.full_eaps.exists() @property def get_status_enum(self) -> EAPStatus: @@ -982,7 +982,7 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): EAPRegistration, on_delete=models.CASCADE, verbose_name=_("EAP Development Registration"), - related_name="simplified_eap", + related_name="simplified_eaps", ) seap_timeframe = models.IntegerField( @@ -1086,11 +1086,11 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - # ENABLE APPROACHES # - enable_approaches = models.ManyToManyField( - EnableApproach, + # ENABLING APPROACHES # + enabling_approaches = models.ManyToManyField( + EnablingApproach, verbose_name=_("Enabling Approaches"), - related_name="simplified_eap_enable_approaches", + related_name="simplified_eap_enabling_approaches", blank=True, ) @@ -1166,6 +1166,7 @@ def generate_snapshot(self): "review_checklist_file": None, "updated_checklist_file": None, "diff_file": None, + "export_file": None, }, exclude_clone_m2m_fields={ "admin2", @@ -1189,7 +1190,7 @@ class FullEAP(EAPBaseModel, CommonEAPFields): EAPRegistration, on_delete=models.CASCADE, verbose_name=_("EAP Development Registration"), - related_name="full_eap", + related_name="full_eaps", ) expected_submission_time = models.DateField( @@ -1422,10 +1423,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): related_name="full_eap_planned_operation", blank=True, ) - enable_approaches = models.ManyToManyField( - EnableApproach, + enabling_approaches = models.ManyToManyField( + EnablingApproach, verbose_name=_("Enabling approaches"), - related_name="full_eap_enable_approaches", + related_name="full_eap_enabling_approaches", blank=True, ) @@ -1595,6 +1596,7 @@ def generate_snapshot(self): "review_checklist_file": None, "updated_checklist_file": None, "diff_file": None, + "export_file": None, }, exclude_clone_m2m_fields={ "admin2", diff --git a/eap/serializers.py b/eap/serializers.py index a1f07f1ed..7d655c21b 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -23,7 +23,7 @@ EAPImpact, EAPRegistration, EAPType, - EnableApproach, + EnablingApproach, FullEAP, HoursTimeFrameChoices, Indicator, @@ -239,8 +239,8 @@ class EAPRegistrationSerializer( disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) # EAPs - simplified_eap_details = MiniSimplifiedEAPSerializer(source="simplified_eap", many=True, read_only=True) - full_eap_details = MiniFullEAPSerializer(source="full_eap", many=True, read_only=True) + 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) @@ -378,7 +378,7 @@ class Meta: fields = "__all__" -class EnableApproachSerializer( +class EnablingApproachSerializer( NestedUpdateMixin, NestedCreateMixin, serializers.ModelSerializer, @@ -395,7 +395,7 @@ class EnableApproachSerializer( early_action_activities = OperationActivitySerializer(many=True, required=True) class Meta: - model = EnableApproach + model = EnablingApproach fields = "__all__" @@ -452,19 +452,6 @@ class Meta: class CommonEAPFieldsSerializer(serializers.ModelSerializer): MAX_NUMBER_OF_IMAGES = 5 - # Partner NS Contact - partner_contacts = EAPContactSerializer(many=True, required=False, allow_null=True) - - planned_operations = PlannedOperationSerializer(many=True, required=True) - enable_approaches = EnableApproachSerializer(many=True, required=True) - - # FILES - cover_image_file = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) - admin2_details = Admin2Serializer(source="admin2", many=True, read_only=True) - budget_file = serializers.PrimaryKeyRelatedField(queryset=EAPFile.objects.all(), required=True) - budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) - updated_checklist_file_details = EAPFileSerializer(source="updated_checklist_file", read_only=True) - def get_fields(self): fields = super().get_fields() fields["partner_contacts"] = EAPContactSerializer(many=True, required=False) @@ -472,7 +459,7 @@ def get_fields(self): 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["enable_approaches"] = EnableApproachSerializer(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) @@ -833,6 +820,25 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % (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 [ @@ -865,6 +871,14 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t 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, diff --git a/eap/test_views.py b/eap/test_views.py index f4f76718d..e1d814de7 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -14,7 +14,7 @@ from eap.factories import ( EAPFileFactory, EAPRegistrationFactory, - EnableApproachFactory, + EnablingApproachFactory, FullEAPFactory, KeyActorFactory, OperationActivityFactory, @@ -26,7 +26,7 @@ EAPFile, EAPStatus, EAPType, - EnableApproach, + EnablingApproach, MonthsTimeFrameChoices, PlannedOperation, SimplifiedEAP, @@ -573,10 +573,10 @@ def test_create_simplified_eap(self): ], } ], - "enable_approaches": [ + "enabling_approaches": [ { "ap_code": 11, - "approach": EnableApproach.Approach.SECRETARIAT_SERVICES, + "approach": EnablingApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, "indicators": [ { @@ -654,17 +654,17 @@ def test_update_simplified_eap(self): created_by=self.country_admin, modified_by=self.country_admin, ) - enable_approach_readiness_operation_activity_1 = OperationActivityFactory.create( + enabling_approach_readiness_operation_activity_1 = OperationActivityFactory.create( activity="Readiness Activity 1", timeframe=TimeFrame.MONTHS, time_value=[MonthsTimeFrameChoices.ONE_MONTH, MonthsTimeFrameChoices.TWO_MONTHS], ) - enable_approach_readiness_operation_activity_2 = OperationActivityFactory.create( + enabling_approach_readiness_operation_activity_2 = OperationActivityFactory.create( activity="Readiness Activity 2", timeframe=TimeFrame.YEARS, time_value=[YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.FIVE_YEARS], ) - enable_approach_prepositioning_operation_activity_1 = OperationActivityFactory.create( + enabling_approach_prepositioning_operation_activity_1 = OperationActivityFactory.create( activity="Prepositioning Activity 1", timeframe=TimeFrame.MONTHS, time_value=[ @@ -672,7 +672,7 @@ def test_update_simplified_eap(self): MonthsTimeFrameChoices.FOUR_MONTHS, ], ) - enable_approach_prepositioning_operation_activity_2 = OperationActivityFactory.create( + enabling_approach_prepositioning_operation_activity_2 = OperationActivityFactory.create( activity="Prepositioning Activity 2", timeframe=TimeFrame.MONTHS, time_value=[ @@ -680,12 +680,12 @@ def test_update_simplified_eap(self): MonthsTimeFrameChoices.SIX_MONTHS, ], ) - enable_approach_early_action_operation_activity_1 = OperationActivityFactory.create( + 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], ) - enable_approach_early_action_operation_activity_2 = OperationActivityFactory.create( + enabling_approach_early_action_operation_activity_2 = OperationActivityFactory.create( activity="Early Action Activity 2", timeframe=TimeFrame.MONTHS, time_value=[ @@ -695,21 +695,21 @@ def test_update_simplified_eap(self): ) # ENABLE APPROACH with activities - enable_approach = EnableApproachFactory.create( - approach=EnableApproach.Approach.SECRETARIAT_SERVICES, + enabling_approach = EnablingApproachFactory.create( + approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, ap_code=123, readiness_activities=[ - enable_approach_readiness_operation_activity_1.id, - enable_approach_readiness_operation_activity_2.id, + enabling_approach_readiness_operation_activity_1.id, + enabling_approach_readiness_operation_activity_2.id, ], prepositioning_activities=[ - enable_approach_prepositioning_operation_activity_1.id, - enable_approach_prepositioning_operation_activity_2.id, + enabling_approach_prepositioning_operation_activity_1.id, + enabling_approach_prepositioning_operation_activity_2.id, ], early_action_activities=[ - enable_approach_early_action_operation_activity_1.id, - enable_approach_early_action_operation_activity_2.id, + enabling_approach_early_action_operation_activity_1.id, + enabling_approach_early_action_operation_activity_2.id, ], ) planned_operation_readiness_operation_activity_1 = OperationActivityFactory.create( @@ -784,7 +784,7 @@ def test_update_simplified_eap(self): created_by=self.country_admin, modified_by=self.country_admin, ), - enable_approaches=[enable_approach.id], + enabling_approaches=[enabling_approach.id], planned_operations=[planned_operation.id], ) url = f"/api/v2/simplified-eap/{simplified_eap.id}/" @@ -795,45 +795,45 @@ def test_update_simplified_eap(self): "readiness_budget": 8000, "pre_positioning_budget": 7000, "early_action_budget": 5000, - "enable_approaches": [ + "enabling_approaches": [ { - "id": enable_approach.id, - "approach": EnableApproach.Approach.NATIONAL_SOCIETY_STRENGTHENING, + "id": enabling_approach.id, + "approach": EnablingApproach.Approach.NATIONAL_SOCIETY_STRENGTHENING, "budget_per_approach": 8000, "ap_code": 123, "readiness_activities": [ { - "id": enable_approach_readiness_operation_activity_1.id, - "activity": "Updated Enable Approach Readiness Activity 1", + "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": enable_approach_prepositioning_operation_activity_1.id, - "activity": "Updated Enable Approach Prepositioning Activity 1", + "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": enable_approach_early_action_operation_activity_1.id, - "activity": "Updated Enable Approach Early Action Activity 1", + "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 Enable Approach + # CREATE NEW Enabling Approach { - "approach": EnableApproach.Approach.PARTNERSHIP_AND_COORDINATION, + "approach": EnablingApproach.Approach.PARTNERSHIP_AND_COORDINATION, "budget_per_approach": 9000, "ap_code": 124, "readiness_activities": [ { - "activity": "New Enable Approach Readiness Activity", + "activity": "New Enabling Approach Readiness Activity", "timeframe": TimeFrame.MONTHS, "time_value": [ MonthsTimeFrameChoices.THREE_MONTHS, @@ -843,7 +843,7 @@ def test_update_simplified_eap(self): ], "prepositioning_activities": [ { - "activity": "New Enable Approach Prepositioning Activity", + "activity": "New Enabling Approach Prepositioning Activity", "timeframe": TimeFrame.MONTHS, "time_value": [ MonthsTimeFrameChoices.SIX_MONTHS, @@ -853,7 +853,7 @@ def test_update_simplified_eap(self): ], "early_action_activities": [ { - "activity": "New Enable Approach Early Action Activity", + "activity": "New Enabling Approach Early Action Activity", "timeframe": TimeFrame.DAYS, "time_value": [ DaysTimeFrameChoices.EIGHT_DAYS, @@ -971,75 +971,75 @@ def test_update_simplified_eap(self): ) # CHECK ENABLE APPROACH UPDATED - self.assertEqual(len(response.data["enable_approaches"]), 2) + self.assertEqual(len(response.data["enabling_approaches"]), 2) self.assertEqual( { - response.data["enable_approaches"][0]["id"], - response.data["enable_approaches"][0]["approach"], - response.data["enable_approaches"][0]["budget_per_approach"], - response.data["enable_approaches"][0]["ap_code"], + 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["enable_approaches"][1]["approach"], - response.data["enable_approaches"][1]["budget_per_approach"], - response.data["enable_approaches"][1]["ap_code"], + response.data["enabling_approaches"][1]["approach"], + response.data["enabling_approaches"][1]["budget_per_approach"], + response.data["enabling_approaches"][1]["ap_code"], }, { - enable_approach.id, - data["enable_approaches"][0]["approach"], - data["enable_approaches"][0]["budget_per_approach"], - data["enable_approaches"][0]["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["enable_approaches"][1]["approach"], - data["enable_approaches"][1]["budget_per_approach"], - data["enable_approaches"][1]["ap_code"], + data["enabling_approaches"][1]["approach"], + data["enabling_approaches"][1]["budget_per_approach"], + data["enabling_approaches"][1]["ap_code"], }, ) self.assertEqual( { # READINESS ACTIVITY - response.data["enable_approaches"][0]["readiness_activities"][0]["id"], - response.data["enable_approaches"][0]["readiness_activities"][0]["activity"], - response.data["enable_approaches"][0]["readiness_activities"][0]["timeframe"], + 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["enable_approaches"][1]["readiness_activities"][0]["activity"], - response.data["enable_approaches"][1]["readiness_activities"][0]["timeframe"], + response.data["enabling_approaches"][1]["readiness_activities"][0]["activity"], + response.data["enabling_approaches"][1]["readiness_activities"][0]["timeframe"], # PREPOSITIONING ACTIVITY - response.data["enable_approaches"][0]["prepositioning_activities"][0]["id"], - response.data["enable_approaches"][0]["prepositioning_activities"][0]["activity"], - response.data["enable_approaches"][0]["prepositioning_activities"][0]["timeframe"], + 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["enable_approaches"][1]["prepositioning_activities"][0]["activity"], - response.data["enable_approaches"][1]["prepositioning_activities"][0]["timeframe"], + response.data["enabling_approaches"][1]["prepositioning_activities"][0]["activity"], + response.data["enabling_approaches"][1]["prepositioning_activities"][0]["timeframe"], # EARLY ACTION ACTIVITY - response.data["enable_approaches"][0]["early_action_activities"][0]["id"], - response.data["enable_approaches"][0]["early_action_activities"][0]["activity"], - response.data["enable_approaches"][0]["early_action_activities"][0]["timeframe"], + 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["enable_approaches"][1]["early_action_activities"][0]["activity"], - response.data["enable_approaches"][1]["early_action_activities"][0]["timeframe"], + response.data["enabling_approaches"][1]["early_action_activities"][0]["activity"], + response.data["enabling_approaches"][1]["early_action_activities"][0]["timeframe"], }, { # READINESS ACTIVITY - enable_approach_readiness_operation_activity_1.id, - data["enable_approaches"][0]["readiness_activities"][0]["activity"], - data["enable_approaches"][0]["readiness_activities"][0]["timeframe"], + 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["enable_approaches"][1]["readiness_activities"][0]["activity"], - data["enable_approaches"][1]["readiness_activities"][0]["timeframe"], + data["enabling_approaches"][1]["readiness_activities"][0]["activity"], + data["enabling_approaches"][1]["readiness_activities"][0]["timeframe"], # PREPOSITIONING ACTIVITY - enable_approach_prepositioning_operation_activity_1.id, - data["enable_approaches"][0]["prepositioning_activities"][0]["activity"], - data["enable_approaches"][0]["prepositioning_activities"][0]["timeframe"], + 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["enable_approaches"][1]["prepositioning_activities"][0]["activity"], - data["enable_approaches"][1]["prepositioning_activities"][0]["timeframe"], + data["enabling_approaches"][1]["prepositioning_activities"][0]["activity"], + data["enabling_approaches"][1]["prepositioning_activities"][0]["timeframe"], # EARLY ACTION ACTIVITY - enable_approach_early_action_operation_activity_1.id, - data["enable_approaches"][0]["early_action_activities"][0]["activity"], - data["enable_approaches"][0]["early_action_activities"][0]["timeframe"], + 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["enable_approaches"][1]["early_action_activities"][0]["activity"], - data["enable_approaches"][1]["early_action_activities"][0]["timeframe"], + data["enabling_approaches"][1]["early_action_activities"][0]["activity"], + data["enabling_approaches"][1]["early_action_activities"][0]["timeframe"], }, ) @@ -1655,6 +1655,8 @@ def test_status_transition(self): 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") @@ -1671,6 +1673,8 @@ def test_status_transitions_trigger_email( send_eap_resubmission_email, send_feedback_email, send_new_eap_submission_email, + mock_group, + generate_export_eap_pdf, ): # Create permissions @@ -1697,8 +1701,8 @@ def test_status_transitions_trigger_email( eap_type=EAPType.SIMPLIFIED_EAP, status=EAPStatus.UNDER_DEVELOPMENT, partners=[self.partner1.id, self.partner2.id], - created_by=self.user, - modified_by=self.user, + created_by=self.country_admin, + modified_by=self.country_admin, ) simplified_eap = SimplifiedEAPFactory.create( eap_registration=eap_registration, @@ -1722,6 +1726,9 @@ def test_status_transitions_trigger_email( 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() @@ -1776,11 +1783,17 @@ def test_status_transitions_trigger_email( 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() @@ -1838,6 +1851,7 @@ def test_status_transitions_trigger_email( 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() @@ -1907,6 +1921,7 @@ def test_status_transitions_trigger_email( 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() @@ -2011,7 +2026,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/" + 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) @@ -2040,7 +2055,7 @@ def test_simplified_eap_export(self, mock_generate_url): self.assert_201(response) self.assertIsNotNone(response.data["id"], response.data) - expected_url = f"{settings.GO_WEB_INTERNAL_URL}/eap/{eap_registration.id}/export/?version=2" + 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") @@ -2079,7 +2094,7 @@ def test_full_eap_export(self, mock_generate_url): 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/" + 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) @@ -2122,14 +2137,12 @@ def test_diff_export_eap(self, mock_generate_url): "diff": True, } - 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/?diff=true" + 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) @@ -2391,10 +2404,10 @@ def test_create_full_eap(self): ], } ], - "enable_approaches": [ + "enabling_approaches": [ { "ap_code": 11, - "approach": EnableApproach.Approach.SECRETARIAT_SERVICES, + "approach": EnablingApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, "indicators": [ { @@ -2539,8 +2552,8 @@ def setUp(self): def test_snapshot_full_eap(self): # Create M2M objects - enable_approach = EnableApproachFactory.create( - approach=EnableApproach.Approach.SECRETARIAT_SERVICES, + enabling_approach = EnablingApproachFactory.create( + approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, ap_code=123, ) @@ -2595,7 +2608,7 @@ def test_snapshot_full_eap(self): modified_by=self.user, ) original.key_actors.add(key_actor_1, key_actor_2) - original.enable_approaches.add(enable_approach) + 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) @@ -2627,8 +2640,8 @@ def test_snapshot_full_eap(self): ) # M2M deeply cloned on approach - orig_approaches = list(original.enable_approaches.all()) - snapshot_approaches = list(snapshot.enable_approaches.all()) + 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) @@ -2684,7 +2697,7 @@ def test_snapshot_full_eap(self): 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.enable_approaches.all(), snapshot.enable_approaches.all()): + 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()): diff --git a/eap/views.py b/eap/views.py index 0fb232c09..96e13ba1a 100644 --- a/eap/views.py +++ b/eap/views.py @@ -16,7 +16,7 @@ EAPRegistration, EAPStatus, EAPType, - EnableApproach, + EnablingApproach, FullEAP, KeyActor, PlannedOperation, @@ -105,7 +105,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: .prefetch_related( "partners", Prefetch( - "simplified_eap", + "simplified_eaps", queryset=SimplifiedEAP.objects.select_related( "budget_file__created_by", "budget_file__modified_by", @@ -114,7 +114,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: ), ), Prefetch( - "full_eap", + "full_eaps", queryset=FullEAP.objects.select_related( "budget_file__created_by", "budget_file__modified_by", @@ -209,8 +209,8 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: ), ), Prefetch( - "enable_approaches", - queryset=EnableApproach.objects.prefetch_related( + "enabling_approaches", + queryset=EnablingApproach.objects.prefetch_related( "indicators", "readiness_activities", "prepositioning_activities", @@ -296,8 +296,8 @@ def get_queryset(self) -> QuerySet[FullEAP]: ), ), Prefetch( - "enable_approaches", - queryset=EnableApproach.objects.prefetch_related( + "enabling_approaches", + queryset=EnablingApproach.objects.prefetch_related( "indicators", "readiness_activities", "prepositioning_activities", From cdec93e58267717e3fb6feedda925933d5fb2033 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 19 Jan 2026 13:56:53 +0545 Subject: [PATCH 55/57] feat(admin2): add filtering by code on admin2 endpoint --- api/filter_set.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/filter_set.py b/api/filter_set.py index 05b696afb..d486a8660 100644 --- a/api/filter_set.py +++ b/api/filter_set.py @@ -162,6 +162,7 @@ class Meta: model = Admin2 fields = { "id": ("exact", "in"), + "code": ("exact", "in"), "admin1": ("exact", "in"), "admin1__country": ("exact", "in"), "admin1__country__iso3": ("exact", "in"), From 4f4b133ea5c0ef9bd78d1f1c4c596dcc6a946347 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 23 Jan 2026 13:54:00 +0545 Subject: [PATCH 56/57] chore(migration): merge migrations for api apps --- api/migrations/0228_merge_20260123_0806.py | 14 ++++++++++++++ assets | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 api/migrations/0228_merge_20260123_0806.py diff --git a/api/migrations/0228_merge_20260123_0806.py b/api/migrations/0228_merge_20260123_0806.py new file mode 100644 index 000000000..82d7be2da --- /dev/null +++ b/api/migrations/0228_merge_20260123_0806.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.26 on 2026-01-23 08:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0227_alter_eventseveritylevelhistory_options'), + ('api', '0227_alter_export_export_type'), + ] + + operations = [ + ] diff --git a/assets b/assets index d5c165961..d98c2b844 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d5c1659610b42aee2e84dfdad35768b0cbc7c7f2 +Subproject commit d98c2b844a4246b2bedd29f6b2c87f5f32a7018d From 143c666b5ed1af04466296cde676f68e99399c02 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Wed, 4 Feb 2026 14:49:58 +0545 Subject: [PATCH 57/57] feat(eap): add diff PDF to pending-PFA email attachment - Update pending-PFA email template to link the auto-generated diff and summary PDFs - Generate summary or diff pdf file if missing --- eap/tasks.py | 21 ++++++++++++------- .../templates/email/eap/pending_pfa.html | 9 ++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/eap/tasks.py b/eap/tasks.py index 21aff9b27..dfbc3a79e 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -476,15 +476,20 @@ def send_pending_pfa_email(eap_registration_id: int): 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 + is_full_eap = instance.get_eap_type_enum == EAPType.FULL_EAP - if not latest_eap.summary_file: - generate_eap_summary_pdf( - eap_registration_id=instance.id, - ) + latest_eap = instance.latest_full_eap if is_full_eap else instance.latest_simplified_eap + + if not latest_eap.diff_file: + generate_export_diff_pdf( + eap_registration_id=instance.id, + version=latest_eap.version, + ) + + if is_full_eap and not instance.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)) diff --git a/notifications/templates/email/eap/pending_pfa.html b/notifications/templates/email/eap/pending_pfa.html index ebbc4800c..cfea19ada 100644 --- a/notifications/templates/email/eap/pending_pfa.html +++ b/notifications/templates/email/eap/pending_pfa.html @@ -31,11 +31,20 @@ {% endif %} + {% if summary_file %}

  • Auto-generated Summary PDF
  • + {% endif %} + {% if diff_file %} +
  • + + Auto-generated PDF extract + +
  • + {% endif %}

    Congratulations again and warm wishes,