From 6e0d11d006b2e4b0e1ea3b21934bf14461317dda Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 4 Nov 2025 14:25:55 +0545 Subject: [PATCH 01/53] 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 6d06b8819e9c6d27ce0ffb329e9abfeda6a66204 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 4 Nov 2025 14:27:14 +0545 Subject: [PATCH 02/53] 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 f4a31cb42..378638af3 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 9c4cfdb6e7280264a6fe9ac58941d7ff9bb58eb1 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 13:44:03 +0545 Subject: [PATCH 03/53] 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 378638af3..4987fa103 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 d5fb920b1860be828e649e796edb323411360bef Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 13:55:17 +0545 Subject: [PATCH 04/53] 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 6da7d0a8416d4f3f013031ebc017d07901f6d9ab Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 16:05:06 +0545 Subject: [PATCH 05/53] 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 67f42e96712455c69c6fb6b61813601d3d56c8b2 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 5 Nov 2025 17:26:12 +0545 Subject: [PATCH 06/53] 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 6846aa0e9b01a9e0833003d3a57c9fcd130ae426 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 6 Nov 2025 11:59:19 +0545 Subject: [PATCH 07/53] 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 dd93de11d5deba60baf690a4abd9bcbac564ba07 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 6 Nov 2025 16:02:29 +0545 Subject: [PATCH 08/53] 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 8bd0f45aef4c22b548c688147c52081274e8427f Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 7 Nov 2025 16:44:36 +0545 Subject: [PATCH 09/53] 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 4987fa103..d5f8cc301 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 73aa050dec9c4d63342f23cf2b9c3346c22ca196 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Sat, 8 Nov 2025 21:41:05 +0545 Subject: [PATCH 10/53] 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 e3a248de7b00935b20aa981bed23986d9ea6cc03 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 11 Nov 2025 12:37:36 +0545 Subject: [PATCH 11/53] 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 268342e5f07d4fe8c1b5d133ce2171d35d997987 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 12 Nov 2025 23:30:53 +0545 Subject: [PATCH 12/53] 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 1be3ac7e74dda0d9d061a9f39047c84d73e473d4 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 13 Nov 2025 17:05:06 +0545 Subject: [PATCH 13/53] 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 9821ded5b1e507f52762268d20b4eba3b1841c6a Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 14 Nov 2025 17:11:32 +0545 Subject: [PATCH 14/53] 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 d5f8cc301..20e7d9629 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 81020559757a1484bf130c9f3dfed34a38e04fcd Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 19 Nov 2025 16:33:33 +0545 Subject: [PATCH 15/53] 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 c422cfc94651865776c2f968cc83070028cc92f0 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 20 Nov 2025 14:27:38 +0545 Subject: [PATCH 16/53] 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 07297ad54086c2a51a6dcfea165a0fabe32d5388 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Wed, 19 Nov 2025 10:29:09 +0545 Subject: [PATCH 17/53] 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 9cfb096bc..3315f7ad0 100644 --- a/api/models.py +++ b/api/models.py @@ -2560,6 +2560,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 cd0ba0d73..1e3568702 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 @@ -2567,6 +2568,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 f61a1f588ca052cc3151d0856cbe5a31358c0bcb Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 25 Nov 2025 11:41:06 +0545 Subject: [PATCH 18/53] 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 2da48226d164e737b0c8f94465c4253070196487 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 25 Nov 2025 17:21:41 +0545 Subject: [PATCH 19/53] 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 b87d3f02effb37c6ad8b67225bdb8e2b50500dce Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Thu, 20 Nov 2025 13:38:50 +0545 Subject: [PATCH 20/53] 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 0baae52a5e0b7ec7ea1b27875aa16043addcd93c Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 21 Nov 2025 17:15:45 +0545 Subject: [PATCH 21/53] 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 20e7d9629..70a515c41 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 012f547b274cc227ebaba8f95966eb37df9f2c6e Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 24 Nov 2025 21:18:18 +0545 Subject: [PATCH 22/53] 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 db3f637418d68bb1a7be24a7bf9da5a5b12435ad Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 24 Nov 2025 23:11:02 +0545 Subject: [PATCH 23/53] 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 c03e1d4e8cdb348a7f5d782259506cd0af1fc2e5 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 25 Nov 2025 15:15:53 +0545 Subject: [PATCH 24/53] 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 46c9801be4140fec6ad3092e731f4376a512d856 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 26 Nov 2025 14:34:23 +0545 Subject: [PATCH 25/53] 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 db3f4dc55ba95af192a051d5ebe6d390656a4e1f Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 26 Nov 2025 15:53:24 +0545 Subject: [PATCH 26/53] 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 3315f7ad0..e3621622e 100644 --- a/api/models.py +++ b/api/models.py @@ -2561,6 +2561,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 1e3568702..80fa58b0f 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 @@ -2573,6 +2573,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 0e1070345ab2d6191d02450f6c8e7d8b0086d5a2 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 26 Nov 2025 21:32:14 +0545 Subject: [PATCH 27/53] 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 0206f5e6f9014c8619830cdbc3362ec509f787f2 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 27 Nov 2025 17:00:26 +0545 Subject: [PATCH 28/53] 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 a685718813b968d48a7f00827048252dfc39d7d0 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 4 Dec 2025 14:27:30 +0545 Subject: [PATCH 29/53] chore(assest): Update asset commit head --- assets | 2 +- docs/go-artifacts.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets b/assets index d6b617c5e..50b47cc5e 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d6b617c5efdd857d398ef5ab569509ae32e8fa18 +Subproject commit 50b47cc5e29656c93509c3507a27e38e7bc13e0b diff --git a/docs/go-artifacts.md b/docs/go-artifacts.md index b75b264e8..3869375c4 100644 --- a/docs/go-artifacts.md +++ b/docs/go-artifacts.md @@ -27,7 +27,7 @@ This is a **GitHub repository** used to store and manage **build files**, **gene Command ```bash -docker compose run --rm serve ./manage.py spectacular --file .assets/openapi-schema.yaml +docker compose run --rm serve ./manage.py spectacular --file ./assets/openapi-schema.yaml ``` ### 🚨 Manually added files From 3f77a55fe3970482e04fbbc06e458f67d111f4ee Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 5 Dec 2025 12:18:54 +0545 Subject: [PATCH 30/53] feat(full-eap): Add new fields on full eap - update test cases - Add actions and impact fields(m2m) --- assets | 2 +- 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 +++- 7 files changed, 434 insertions(+), 54 deletions(-) create mode 100644 eap/migrations/0010_eapaction_eapimpact_indicator_and_more.py diff --git a/assets b/assets index 50b47cc5e..9ad4cdd1c 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 50b47cc5e29656c93509c3507a27e38e7bc13e0b +Subproject commit 9ad4cdd1cead0e29e8602359dda3e7383cc9d8f2 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 f0f06379fbc6742d5f44675618e13eb60de17e42 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 10 Dec 2025 14:16:20 +0545 Subject: [PATCH 31/53] feat(full-eap): Add new status and update on status transition - Add latest eap on registration - Update on status transition --- assets | 2 +- 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 ++- 7 files changed, 390 insertions(+), 91 deletions(-) diff --git a/assets b/assets index 9ad4cdd1c..1b443a676 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 9ad4cdd1cead0e29e8602359dda3e7383cc9d8f2 +Subproject commit 1b443a676cb3ee1f6b9a39f11e8151abbfccd06f 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 efcbeda64ec0a59b52d1f66b5402b288e90b8e91 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 10 Dec 2025 14:52:16 +0545 Subject: [PATCH 32/53] feat(full-eap): Add new field forecast table file --- assets | 2 +- 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 + 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/assets b/assets index 1b443a676..724cdf25c 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 1b443a676cb3ee1f6b9a39f11e8151abbfccd06f +Subproject commit 724cdf25cc2000c06004e3d57590faf58b8ea4f4 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 b3d372a4412f68a1709e44871bcf0730e36eac0a Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 11 Dec 2025 15:36:51 +0545 Subject: [PATCH 33/53] 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 d622b2acb44b972bdbc71f1830319580c66ffc2a Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 11:18:26 +0545 Subject: [PATCH 34/53] feat(eap): Add multiple validation checks for files --- assets | 2 +- eap/serializers.py | 23 ++++++++++++++ eap/test_views.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/assets b/assets index 724cdf25c..93e51afb8 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 724cdf25cc2000c06004e3d57590faf58b8ea4f4 +Subproject commit 93e51afb8c6887384ce46cbfcbc01f04f8c06969 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 85a119b2194e74665dee9f9a5cd773ad0a73f1ae Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 11:56:29 +0545 Subject: [PATCH 35/53] chore(assets): Update assets commit reference --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index 93e51afb8..81b4e7e6d 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 93e51afb8c6887384ce46cbfcbc01f04f8c06969 +Subproject commit 81b4e7e6d6015c72a2c8e6b36d7da91191c828b4 From 4da83f479d799f20bd837cd3184e5cf87cd912a9 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 12:43:24 +0545 Subject: [PATCH 36/53] fix(eap): typing issue on eap actiona and source information --- assets | 2 +- eap/serializers.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/assets b/assets index 81b4e7e6d..585e4c8ee 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 81b4e7e6d6015c72a2c8e6b36d7da91191c828b4 +Subproject commit 585e4c8eea8c255aab7830437b82b20c85d94cce 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 e16bab7966a4e6bb106bf224f5e56c72552daff6 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Thu, 4 Dec 2025 15:28:19 +0545 Subject: [PATCH 37/53] 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 e3621622e..9e6acc911 100644 --- a/api/models.py +++ b/api/models.py @@ -2560,8 +2560,8 @@ class ExportType(models.TextChoices): FINAL_REPORT = "dref-final-reports", _("DREF Final Report") OLD_FINAL_REPORT = "old-dref-final-reports", _("Old DREF Final Report") PER = "per", _("Per") - SIMPLIFIED_EAP = "simplified-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 80fa58b0f..3fb34c042 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2544,6 +2544,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 @@ -2559,6 +2564,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: @@ -2569,12 +2575,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" @@ -2582,6 +2596,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 64ea825fe98844b952821056f14870326ef903f9 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 5 Dec 2025 16:34:34 +0545 Subject: [PATCH 38/53] 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 3fb34c042..ec77e906c 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2559,12 +2559,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: @@ -2575,20 +2588,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" @@ -2597,17 +2610,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 abc20b94143333aa7ab713abda914374b8da0c34 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 12 Dec 2025 14:15:51 +0545 Subject: [PATCH 39/53] feat(eap): Update on Export url for eaps - Add default ordering - Add created and modified at --- api/serializers.py | 57 +++++++++++++++++++++++++++------------------- assets | 2 +- eap/serializers.py | 6 +++++ eap/test_views.py | 38 +++++++++++++++++++------------ eap/views.py | 1 - 5 files changed, 63 insertions(+), 41 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index ec77e906c..4d8496b05 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 @@ -2559,19 +2559,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") @@ -2588,20 +2575,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/assets b/assets index 585e4c8ee..31f596014 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 585e4c8eea8c255aab7830437b82b20c85d94cce +Subproject commit 31f5960143b09b0d9cfd4e729760d2517783758e 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 c2beacf56f65036c9e47d189a1220a3d80c37878 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 15 Dec 2025 13:23:06 +0545 Subject: [PATCH 40/53] fix(eap): Replace update checklist file to EAPFile --- assets | 2 +- ...fulleap_updated_checklist_file_and_more.py | 35 ++++++++++ eap/models.py | 7 +- eap/serializers.py | 4 +- eap/test_views.py | 65 ++++++++++--------- 5 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 eap/migrations/0011_alter_fulleap_updated_checklist_file_and_more.py diff --git a/assets b/assets index 31f596014..1057847ee 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 31f5960143b09b0d9cfd4e729760d2517783758e +Subproject commit 1057847ee145f36774c8c9216751e4268d02167e 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 cf7bb7efefeb9401fcc4c083b4e90e40e50ede78 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Mon, 15 Dec 2025 14:05:34 +0545 Subject: [PATCH 41/53] 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 4d8496b05..b14d7d9e6 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2623,7 +2623,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 971f3a13a9abef59d314540f26115fac5e141d1d Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 19 Dec 2025 12:06:45 +0545 Subject: [PATCH 42/53] chore(fulleap): Remove fields from fulleap model (#2614) --- assets | 2 +- 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 +- 6 files changed, 202 insertions(+), 36 deletions(-) create mode 100644 eap/migrations/0012_remove_fulleap_seap_timeframe_and_more.py diff --git a/assets b/assets index 1057847ee..c2ef12ccd 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 1057847ee145f36774c8c9216751e4268d02167e +Subproject commit c2ef12ccd2a0440e448310352dd8625bb1987cb4 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 30f45895b2cb1b5d892187aafe3121130a649470 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 19 Dec 2025 13:43:56 +0545 Subject: [PATCH 43/53] chore(eap-registration): Update fields on eap registration --- assets | 2 +- ...national_society_contact_email_and_more.py | 32 +++++++++++++++++++ eap/models.py | 6 ++-- eap/test_views.py | 4 +-- 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 eap/migrations/0013_alter_eapregistration_national_society_contact_email_and_more.py diff --git a/assets b/assets index c2ef12ccd..5f55b6735 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit c2ef12ccd2a0440e448310352dd8625bb1987cb4 +Subproject commit 5f55b673507e6323eb10a8a76f19552d73786c5a 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 e1e7a5694ff47eff8872e6f1364f6825ee74b4d9 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Thu, 4 Dec 2025 10:08:36 +0545 Subject: [PATCH 44/53] feat(eap): Add email setup and template --- assets | 2 +- eap/dev_views.py | 118 ++++++++++++++++++ eap/serializers.py | 12 ++ eap/tasks.py | 58 +++++++++ eap/test_views.py | 4 +- eap/utils.py | 46 +++++++ main/settings.py | 18 +++ main/urls.py | 2 + notifications/notification.py | 30 +++-- .../templates/email/eap/approved.html | 33 +++++ .../eap/feedback_to_national_society.html | 49 ++++++++ .../email/eap/feedback_to_revised_eap.html | 57 +++++++++ .../templates/email/eap/pending_pfa.html | 33 +++++ .../templates/email/eap/re-submission.html | 100 +++++++++++++++ .../templates/email/eap/registration.html | 54 ++++++++ .../templates/email/eap/reminder.html | 29 +++++ .../templates/email/eap/submission.html | 78 ++++++++++++ .../email/eap/technically_validated_eap.html | 45 +++++++ 18 files changed, 755 insertions(+), 13 deletions(-) create mode 100644 eap/dev_views.py create mode 100644 eap/tasks.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/assets b/assets index 5f55b6735..db4b64c17 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 5f55b673507e6323eb10a8a76f19552d73786c5a +Subproject commit db4b64c173cbc3ff25e80c7a4b35d171271b091e diff --git a/eap/dev_views.py b/eap/dev_views.py new file mode 100644 index 000000000..9d6d5b458 --- /dev/null +++ b/eap/dev_views.py @@ -0,0 +1,118 @@ +from django.http import HttpResponse +from django.template import loader +from rest_framework import permissions +from rest_framework.views import APIView + + +class EAPEmailPreview(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + type_param = request.GET.get("type") + + template_map = { + "registration": "email/eap/registration.html", + "submission": "email/eap/submission.html", + "feedback_to_national_society": "email/eap/feedback_to_national_society.html", + "resubmission_of_revised_eap": "email/eap/re-submission.html", + "feedback_for_revised_eap": "email/eap/feedback_to_revised_eap.html", + "technically_validated_eap": "email/eap/technically_validated_eap.html", + "pending_pfa": "email/eap/pending_pfa.html", + "approved_eap": "email/eap/approved.html", + "reminder": "email/eap/reminder.html", + } + + if type_param not in template_map: + valid_values = ", ".join(template_map.keys()) + return HttpResponse( + f"Invalid 'type' parameter. Please use one of the following values: {valid_values}.", + ) + + context_map = { + "registration": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "supporting_partners": [ + {"society_name": "Partner 1"}, + {"society_name": "Partner 2"}, + ], + "disaster_type": "Flood", + "ns_contact_name": "Test registration name", + "ns_contact_email": "test.registration@example.com", + "ns_contact_phone": "1234567890", + }, + "submission": { + "eap_type_display": "SIMPLIFIED EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "people_targated": 100, + "supporting_partners": [ + {"society_name": "Partner NS 1"}, + {"society_name": "Partner NS 2"}, + ], + "disaster_type": "Flood", + "total_budget": "250,000 CHF", + "ns_contact_name": "Test Ns Contact name", + "ns_contact_email": "test.Ns@gmail.com", + "ns_contact_phone": "+977-9800000000", + }, + "feedback_to_national_society": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + }, + "resubmission_of_revised_eap": { + "eap_type_display": "SIMPLIFIED EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "supporting_partners": [ + {"society_name": "Partner NS 1"}, + {"society_name": "Partner NS 2"}, + ], + "version": 2 or 3, + "people_targated": 100, + "disaster_type": "Flood", + "total_budget": "250,000 CHF", + "ns_contact_name": "Test Ns Contact name", + "ns_contact_email": "test.Ns@gmail.com", + "ns_contact_phone": "+977-9800000000", + }, + "feedback_for_revised_eap": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "version": 2, + }, + "technically_validated_eap": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "pending_pfa": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "approved_eap": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + "reminder": { + "eap_type_display": "FULL EAP", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, + } + + context = context_map.get(type_param) + if context is None: + return HttpResponse("No context found for the email preview.") + template_file = template_map[type_param] + template = loader.get_template(template_file) + return HttpResponse(template.render(context, request)) diff --git a/eap/serializers.py b/eap/serializers.py index 01a9b5b89..f1b61b777 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,6 +1,7 @@ import typing 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 @@ -32,6 +33,7 @@ TimeFrame, YearsTimeFrameChoices, ) +from eap.tasks import send_new_eap_registration_email from eap.utils import ( has_country_permission, is_user_ifrc_admin, @@ -184,6 +186,16 @@ class Meta: "latest_full_eap", ] + 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: diff --git a/eap/tasks.py b/eap/tasks.py new file mode 100644 index 000000000..073b32501 --- /dev/null +++ b/eap/tasks.py @@ -0,0 +1,58 @@ +import logging + +from celery import shared_task +from django.conf import settings +from django.contrib.auth import get_user_model +from django.template.loader import render_to_string + +from eap.models import EAPRegistration +from eap.utils import ( + get_coordinator_emails_by_region, + get_eap_registration_email_context, +) +from notifications.notification import send_notification + +User = get_user_model() + +logger = logging.getLogger(__name__) + + +@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 = 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 diff --git a/eap/test_views.py b/eap/test_views.py index 97b1d0661..903ea8aaa 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_eap_registration_email") + def test_create_eap_registration(self, send_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_eap_registration_email) def test_retrieve_eap_registration(self): eap_registration = EAPRegistrationFactory.create( diff --git a/eap/utils.py b/eap/utils.py index dbc72fe18..7ba297b2f 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -1,10 +1,56 @@ 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 + +REGION_EMAIL_MAP = { + 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_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 has_country_permission(user: User, country_id: int) -> bool: """Checks if the user has country admin permission.""" 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 70a515c41..58a0bcc93 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 @@ -287,6 +288,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..d11beab0d --- /dev/null +++ b/notifications/templates/email/eap/feedback_to_national_society.html @@ -0,0 +1,49 @@ +{% 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. +

+ +

+ Attachments:
+ Review checklist uploaded by IFRC
+

+ + +

+ Kind regards,
+ IFRC-DREF AA Team
+ You can access your GO account and check the progress of your EAP here. +

+
+ +{% include "design/foot1.html" %} 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..d0c0793bb --- /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 {{ 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. +

+ + +

+ Kind regards,
+ IFRC-DREF AA Team
+ You can access your GO account and check the progress of your EAP here. +

+ + +

+ Attached documents:
+ Review checklist uploaded by IFRC +

+ +
+ +{% 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..0ae1ec304 --- /dev/null +++ b/notifications/templates/email/eap/pending_pfa.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}} 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. +

+ +

+ 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..dda0ab01b --- /dev/null +++ b/notifications/templates/email/eap/re-submission.html @@ -0,0 +1,100 @@ +{% include "design/head3.html" %} + + + + + +
+

Dear colleagues,

+

+ {{ national_society }} + is hereby submitting the {{ 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_targated }} +
+ 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:

+

Revised EAP PDF with modifications in tracked changes:

+

Revised EAP budget:

+

Review checklist with National Society response to comments:

+ + +

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

+ +
+ +{% include "design/foot1.html" %} 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..89f2fd3b8 --- /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..28fc68800 --- /dev/null +++ b/notifications/templates/email/eap/submission.html @@ -0,0 +1,78 @@ +{% 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|default:"Not Sure" }}
Hazard{{ disaster_type }}
People targeted100
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:
+ Export Pdf:
+ budget file: +

+ + +

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

+ +
+ +{% include "design/foot1.html" %} 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..fa9cd7d38 --- /dev/null +++ b/notifications/templates/email/eap/technically_validated_eap.html @@ -0,0 +1,45 @@ +{% 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. +

+ +

+ Congratulations again and warm wishes,
+ IFRC-DREF AA Team
+ You can access your GO account and check the progress of your EAP here. +

+ +

+ Attachments: +

+ Review Checklist +

+

+ +
+ +{% include "design/foot1.html" %} From e542dc1c13b88a828047ab93cac387c7b8a89327 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Tue, 9 Dec 2025 14:31:32 +0545 Subject: [PATCH 45/53] chore(submodule): Update submodule reference --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 0957519ac..0837dda98 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "assets"] path = assets - url = https://github.com/IFRCGo/go-api-artifacts + url = git@github.com:IFRCGo/go-api-artifacts.git From 8f8ca407455bda39ca33d3c60e296a69a9104f27 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Mon, 15 Dec 2025 14:15:57 +0545 Subject: [PATCH 46/53] fix(eap): align test with renamed task send_new_eap_registration_email --- eap/test_views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eap/test_views.py b/eap/test_views.py index 903ea8aaa..5268b4477 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -122,8 +122,8 @@ def test_list_eap_registration(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["results"]), 5) - @mock.patch("eap.tasks.send_eap_registration_email") - def test_create_eap_registration(self, send_eap_registration_email): + @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, @@ -160,7 +160,7 @@ def test_create_eap_registration(self, send_eap_registration_email): self.disaster_type.id, }, ) - self.assertTrue(send_eap_registration_email) + self.assertTrue(send_new_eap_registration_email) def test_retrieve_eap_registration(self): eap_registration = EAPRegistrationFactory.create( From 65857fbe81047d3e94442330b86fb1f683e39d54 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Mon, 15 Dec 2025 20:46:45 +0545 Subject: [PATCH 47/53] chore(eap): Add typing for regional coordinator email mapping --- eap/tasks.py | 2 +- eap/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/eap/tasks.py b/eap/tasks.py index 073b32501..616819951 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -24,7 +24,7 @@ def send_new_eap_registration_email(eap_registration_id: int): if not instance: return None - regional_coordinator_emails = get_coordinator_emails_by_region(instance.country.region) + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) recipients = [ settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, diff --git a/eap/utils.py b/eap/utils.py index 7ba297b2f..a29498778 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -8,7 +8,7 @@ from api.models import Region, RegionName -REGION_EMAIL_MAP = { +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, From ccdfe40026d945418da8b91068c15ecc543889c1 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Thu, 18 Dec 2025 17:38:37 +0545 Subject: [PATCH 48/53] feat(eap): Add EAP email workflow for different status transition --- eap/serializers.py | 94 ++++- eap/tasks.py | 388 +++++++++++++++++- eap/test_views.py | 307 ++++++++++++++ eap/utils.py | 31 +- .../eap/feedback_to_national_society.html | 6 +- .../email/eap/feedback_to_revised_eap.html | 8 +- .../templates/email/eap/pending_pfa.html | 15 +- .../templates/email/eap/re-submission.html | 15 +- .../templates/email/eap/submission.html | 10 +- .../email/eap/technically_validated_eap.html | 4 +- 10 files changed, 849 insertions(+), 29 deletions(-) diff --git a/eap/serializers.py b/eap/serializers.py index f1b61b777..b6d85f415 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -33,7 +33,16 @@ TimeFrame, YearsTimeFrameChoices, ) -from eap.tasks import send_new_eap_registration_email +from eap.tasks import ( + send_approved_email, + send_eap_resubmission_email, + send_feedback_email, + send_feedback_email_for_resubmitted_eap, + send_new_eap_registration_email, + send_new_eap_submission_email, + send_pending_pfa_email, + send_technical_validation_email, +) from eap.utils import ( has_country_permission, is_user_ifrc_admin, @@ -905,3 +914,86 @@ 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 + """ + + if eap_count == 2: + transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id)) + elif eap_count > 2: + transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + EAPRegistration.Status.UNDER_REVIEW, + ): + transaction.on_commit(lambda: send_eap_resubmission_email.delay(eap_registration_id)) + elif (old_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.NS_ADDRESSING_COMMENTS, + ): + transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.UNDER_REVIEW, + EAPRegistration.Status.TECHNICALLY_VALIDATED, + ): + transaction.on_commit(lambda: send_technical_validation_email.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.TECHNICALLY_VALIDATED, + EAPRegistration.Status.PENDING_PFA, + ): + transaction.on_commit(lambda: send_pending_pfa_email.delay(eap_registration_id)) + + elif (old_status, new_status) == ( + EAPRegistration.Status.PENDING_PFA, + EAPRegistration.Status.APPROVED, + ): + transaction.on_commit(lambda: send_approved_email.delay(eap_registration_id)) + + return updated_instance diff --git a/eap/tasks.py b/eap/tasks.py index 616819951..819ae4ddd 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -5,11 +5,8 @@ from django.contrib.auth import get_user_model from django.template.loader import render_to_string -from eap.models import EAPRegistration -from eap.utils import ( - get_coordinator_emails_by_region, - get_eap_registration_email_context, -) +from eap.models import EAPRegistration, EAPType, FullEAP, SimplifiedEAP +from eap.utils import get_coordinator_emails_by_region, get_eap_email_context from notifications.notification import send_notification User = get_user_model() @@ -39,7 +36,7 @@ def send_new_eap_registration_email(eap_registration_id: int): ] ) ) - email_context = get_eap_registration_email_context(instance) + email_context = get_eap_email_context(instance) email_subject = ( f"[{instance.get_eap_type_display() if instance.get_eap_type_display() else 'EAP'} IN DEVELOPMENT] " f"{instance.country} {instance.disaster_type}" @@ -56,3 +53,382 @@ def send_new_eap_registration_email(eap_registration_id: int): ) return email_context + + +@shared_task +def send_new_eap_submission_email(eap_registration_id: int): + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_email + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + instance.ifrc_contact_email, + ] + cc_recipients = list( + set( + [ + partner_ns_email, + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FOR REVIEW] " f"{instance.country} {instance.disaster_type} TO THE IFRC-DREF" + ) + email_body = render_to_string("email/eap/submission.html", email_context) + email_type = "EAP Submission" + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_feedback_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + ifrc_delegation_focal_point_email = latest_simplified_eap.ifrc_delegation_focal_point_email + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_name + ifrc_delegation_focal_point_email = latest_full_eap.ifrc_delegation_focal_point_email + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + ifrc_delegation_focal_point_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FEEDBACK] " + f"{instance.country} {instance.disaster_type} TO THE {instance.national_society}" + ) + email_body = render_to_string("email/eap/feedback_to_national_society.html", email_context) + email_type = "Feedback to the National Society" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_eap_resubmission_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + latest_version = latest_simplified_eap.version + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_name + latest_version = latest_full_eap.version + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + instance.ifrc_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + instance.national_society_contact_email, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FOR REVIEW] " + f"{instance.country} {instance.disaster_type} version {latest_version} TO THE IFRC-DREF" + ) + email_body = render_to_string("email/eap/re-submission.html", email_context) + email_type = "Feedback to the National Society" + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_feedback_email_for_resubmitted_eap(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + partner_ns_email = instance.latest_simplified_eap.partner_ns_email + latest_version = instance.latest_simplified_eap.version + qs = SimplifiedEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first() + previous_version = qs.version if qs else None + + else: + partner_ns_email = instance.latest_full_eap.partner_ns_email + latest_version = instance.latest_full_eap.version + qs = FullEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first() + previous_version = qs.version if qs else None + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipients = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = ( + f"[DREF {instance.get_eap_type_display()} FEEDBACK] " + f"{instance.country} {instance.disaster_type} version {previous_version} TO {instance.national_society}" + ) + email_body = render_to_string("email/eap/feedback_to_revised_eap.html", email_context) + email_type = "Feedback to the National Society" + send_notification( + subject=email_subject, + recipients=recipients, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + + return email_context + + +@shared_task +def send_technical_validation_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_name + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} TECHNICALLY VALIDATED] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/technically_validated_eap.html", email_context) + email_type = "Technically Validated EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context + + +@shared_task +def send_pending_pfa_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_name + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED PENDING PFA] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/pending_pfa.html", email_context) + email_type = "Approved Pending PFA EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context + + +@shared_task +def send_approved_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + email_context = "Simplified EAP" + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_name + email_context = "Full EAP" + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/approved.html", email_context) + email_type = "Approved EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context + + +@shared_task +def send_deadline_reminder_email(eap_registration_id: int): + + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance: + return None + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_simplified_eap = instance.latest_simplified_eap + partner_ns_email = latest_simplified_eap.partner_ns_email + else: + latest_full_eap = instance.latest_full_eap + partner_ns_email = latest_full_eap.partner_ns_name + + regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) + + recipient = [ + instance.national_society_contact_email, + ] + + cc_recipients = list( + set( + [ + partner_ns_email, + settings.EMAIL_EAP_DREF_ANTICIPATORY_PILLAR, + *settings.EMAIL_EAP_DREF_AA_GLOBAL_TEAM, + *regional_coordinator_emails, + ] + ) + ) + email_context = get_eap_email_context(instance) + email_subject = f"[DREF {instance.get_eap_type_display()} SUBMISSION REMINDER] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/approved.html", email_context) + email_type = "Approved EAP" + send_notification( + subject=email_subject, + recipients=recipient, + html=email_body, + mailtype=email_type, + cc_recipients=cc_recipients, + ) + return email_context diff --git a/eap/test_views.py b/eap/test_views.py index 5268b4477..f94e53ccc 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1608,6 +1608,313 @@ def test_status_transition(self): self.eap_registration.refresh_from_db() self.assertIsNotNone(self.eap_registration.activated_at) + @mock.patch("eap.serializers.send_new_eap_submission_email") + @mock.patch("eap.serializers.send_feedback_email") + @mock.patch("eap.serializers.send_eap_resubmission_email") + @mock.patch("eap.serializers.send_technical_validation_email") + @mock.patch("eap.serializers.send_feedback_email_for_resubmitted_eap") + @mock.patch("eap.serializers.send_pending_pfa_email") + @mock.patch("eap.serializers.send_approved_email") + def test_status_transitions_trigger_email( + self, + send_approved_email, + send_pending_pfa_email, + send_feedback_email_for_resubmitted_eap, + send_technical_validation_email, + send_eap_resubmission_email, + send_feedback_email, + send_new_eap_submission_email, + ): + + # Create permissions + management.call_command("make_permissions") + + self.country_admin = UserFactory.create() + country_admin_permission = Permission.objects.filter(codename="country_admin_%s" % self.national_society.id).first() + country_group = Group.objects.filter(name="%s Admins" % self.national_society.name).first() + + self.country_admin.user_permissions.add(country_admin_permission) + self.country_admin.groups.add(country_group) + + # Create IFRC Admin User and assign permission + self.ifrc_admin_user = UserFactory.create() + ifrc_admin_permission = Permission.objects.filter(codename="ifrc_admin").first() + ifrc_group = Group.objects.filter(name="IFRC Admins").first() + self.ifrc_admin_user.user_permissions.add(ifrc_admin_permission) + self.ifrc_admin_user.groups.add(ifrc_group) + + eap_registration = EAPRegistrationFactory.create( + country=self.country, + national_society=self.national_society, + disaster_type=self.disaster_type, + eap_type=EAPType.SIMPLIFIED_EAP, + status=EAPStatus.UNDER_DEVELOPMENT, + partners=[self.partner1.id, self.partner2.id], + created_by=self.user, + modified_by=self.user, + ) + simplified_eap = SimplifiedEAPFactory.create( + eap_registration=eap_registration, + created_by=self.country_admin, + modified_by=self.country_admin, + budget_file=EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ), + ) + eap_registration.latest_simplified_eap = simplified_eap + eap_registration.save() + + url = f"/api/v2/eap-registration/{eap_registration.id}/status/" + + # UNDER_DEVELOPMENT -> UNDER_REVIEW + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_new_eap_submission_email.delay.assert_called_once_with(eap_registration.id) + send_new_eap_submission_email.delay.reset_mock() + + # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + } + # Login as IFRC admin user + self.authenticate(self.ifrc_admin_user) + + # Upload checklist and change status in a single request + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + "review_checklist_file": tmp_file, + } + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="multipart") + + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.review_checklist_file, + ) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + send_feedback_email.delay.assert_called_once_with(eap_registration.id) + send_feedback_email.delay.reset_mock() + # ----------------------------- + # Check snapshots after the status change + # ----------------------------- + snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() + assert snapshot is not None, "Snapshot should exist now" + eap_registration.latest_simplified_eap = snapshot + eap_registration.save() + + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + # Upload updated checklist file + # UPDATES on the second snapshot + snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/" + checklist_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "eap_registration": snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + self.authenticate(self.country_admin) + response = self.client.patch(snapshot_url, file_data, format="json") + self.assert_200(response) + + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id) + send_eap_resubmission_email.delay.reset_mock() + + # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS + # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS + # Login as IFRC admin user + self.authenticate(self.ifrc_admin_user) + + # Upload checklist and change status in a single request + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + "review_checklist_file": tmp_file, + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="multipart") + + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.review_checklist_file, + ) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id) + send_feedback_email_for_resubmitted_eap.delay.reset_mock() + # ----------------------------- + # Check snapshots after the status change + # ----------------------------- + snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() + assert snapshot is not None, "Snapshot should exist now" + eap_registration.latest_simplified_eap = snapshot + eap_registration.save() + + # NOTE: Again Transition to UNDER_REVIEW + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/" + checklist_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "eap_registration": snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + self.authenticate(self.country_admin) + response = self.client.patch(snapshot_url, file_data, format="json") + self.assert_200(response) + + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id) + send_eap_resubmission_email.delay.reset_mock() + + # Transition UNDER_REVIEW -> TECHNICALLY_VALIDATED + data = {"status": EAPStatus.TECHNICALLY_VALIDATED} + self.authenticate(self.ifrc_admin_user) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED) + eap_registration.refresh_from_db() + send_technical_validation_email.delay.assert_called_once_with(eap_registration.id) + send_technical_validation_email.delay.reset_mock() + + # Transition TECHNICALLY_VALIDATED -> NS_ADDRESSING_COMMENTS + # Login as IFRC admin user + self.authenticate(self.ifrc_admin_user) + + # Upload checklist and change status in a single request + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + + data = { + "status": EAPStatus.NS_ADDRESSING_COMMENTS, + "review_checklist_file": tmp_file, + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="multipart") + + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertIsNotNone( + self.eap_registration.review_checklist_file, + ) + self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) + send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id) + send_feedback_email_for_resubmitted_eap.delay.reset_mock() + # ----------------------------- + # Check snapshots after the status change + # ----------------------------- + snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() + assert snapshot is not None, "Snapshot should exist now" + simplified_eap.refresh_from_db() + eap_registration.latest_simplified_eap = snapshot + eap_registration.save() + + # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW + # Upload updated checklist file + # UPDATES on the second snapshot + snapshot_url = f"/api/v2/simplified-eap/{snapshot.id}/" + checklist_file_instance = EAPFileFactory._create_file( + created_by=self.country_admin, + modified_by=self.country_admin, + ) + file_data = { + "prioritized_hazard_and_impact": "Floods with potential heavy impact.", + "eap_registration": snapshot.eap_registration_id, + "updated_checklist_file": checklist_file_instance.id, + } + self.authenticate(self.country_admin) + response = self.client.patch(snapshot_url, file_data, format="json") + self.assert_200(response) + + data = {"status": EAPStatus.UNDER_REVIEW} + self.authenticate(self.country_admin) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + send_eap_resubmission_email.delay.assert_called_once_with(eap_registration.id) + send_eap_resubmission_email.delay.reset_mock() + + # Again Transition UNDER_REVIEW -> TECHNICALLY_VALIDATED + data = {"status": EAPStatus.TECHNICALLY_VALIDATED} + self.authenticate(self.ifrc_admin_user) + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + eap_registration.refresh_from_db() + self.assertEqual(response.data["status"], EAPStatus.TECHNICALLY_VALIDATED) + send_technical_validation_email.delay.assert_called_once_with(eap_registration.id) + send_technical_validation_email.delay.reset_mock() + + # Transition TECHNICALLY_VALIDATED -> PENDING_PFA + # Upload validated budget file + data = {"status": EAPStatus.PENDING_PFA} + upload_url = f"/api/v2/eap-registration/{eap_registration.id}/upload-validated-budget-file/" + with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: + tmp_file.write(b"Test content") + tmp_file.seek(0) + file_data = {"validated_budget_file": tmp_file} + self.authenticate(self.ifrc_admin_user) + response = self.client.post(upload_url, file_data, format="multipart") + self.assert_200(response) + + # Now change status → PENDING_PFA + status_url = f"/api/v2/eap-registration/{eap_registration.id}/status/" + data = {"status": EAPStatus.PENDING_PFA} + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(status_url, data, format="json") + self.assert_200(response) + self.assertEqual(response.data["status"], EAPStatus.PENDING_PFA) + eap_registration.refresh_from_db() + send_pending_pfa_email.delay.assert_called_once_with(eap_registration.id) + + # Transition PENDING_PFA -> APPROVED + data = {"status": EAPStatus.APPROVED} + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assert_200(response) + self.assertEqual(response.data["status"], EAPStatus.APPROVED) + eap_registration.refresh_from_db() + send_approved_email.delay.assert_called_once_with(eap_registration.id) + class EAPPDFExportTestCase(APITestCase): def setUp(self): diff --git a/eap/utils.py b/eap/utils.py index a29498778..83c4c1c76 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -7,6 +7,7 @@ from django.db import models from api.models import Region, RegionName +from eap.models import EAPType, FullEAP, SimplifiedEAP REGION_EMAIL_MAP: dict[RegionName, list[str]] = { RegionName.AFRICA: settings.EMAIL_EAP_AFRICA_COORDINATORS, @@ -32,7 +33,11 @@ def get_coordinator_emails_by_region(region: Region | None) -> list[str]: return REGION_EMAIL_MAP.get(region.name, []) -def get_eap_registration_email_context(instance): +# TODO @sudip-khanal: Add files to email context after implementing file sending in email notification +# also include the deadline field once it added to the model. + + +def get_eap_email_context(instance): from eap.serializers import EAPRegistrationSerializer eap_registration_data = EAPRegistrationSerializer(instance).data @@ -48,7 +53,31 @@ def get_eap_registration_email_context(instance): "ns_contact_email": eap_registration_data["national_society_contact_email"], "ns_contact_phone": eap_registration_data["national_society_contact_phone_number"], "frontend_url": settings.GO_WEB_URL, + # "review_checklist_file":eap_registration_data["review_checklist_file"], + # "validated_budget_file":eap_registration_data["validated_budget_file"], } + + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + latest_eap_data = instance.latest_simplified_eap + latest_version = instance.latest_simplified_eap.version + qs = SimplifiedEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first() + previous_version = qs.version if qs else None + else: + latest_eap_data = instance.latest_full_eap + latest_version = instance.latest_full_eap + qs = FullEAP.objects.filter(eap_registration=instance, version__lt=latest_version).order_by("-version").first() + previous_version = qs.version if qs else None + + email_context.update( + { + "people_targeted": latest_eap_data.people_targeted, + "total_budget": latest_eap_data.total_budget, + "latest_version": latest_eap_data.version, + "previous_version": previous_version, + # "updated_checklist_file": latest_eap_data.updated_checklist_file, + # "budget_file":latest_eap_data.budget_file, + } + ) return email_context diff --git a/notifications/templates/email/eap/feedback_to_national_society.html b/notifications/templates/email/eap/feedback_to_national_society.html index d11beab0d..1e60ac8b3 100644 --- a/notifications/templates/email/eap/feedback_to_national_society.html +++ b/notifications/templates/email/eap/feedback_to_national_society.html @@ -33,17 +33,17 @@

Attachments:
- Review checklist uploaded by IFRC
+ Review checklist uploaded by IFRC {{ review_checklist_file }}

Kind regards,
IFRC-DREF AA Team
- You can access your GO account and check the progress of your EAP here. + You can access your GO account and check the progress of your EAP here.

-{% include "design/foot1.html" %} +{% include "design/foot1.html" %} \ No newline at end of file diff --git a/notifications/templates/email/eap/feedback_to_revised_eap.html b/notifications/templates/email/eap/feedback_to_revised_eap.html index d0c0793bb..0dbd98177 100644 --- a/notifications/templates/email/eap/feedback_to_revised_eap.html +++ b/notifications/templates/email/eap/feedback_to_revised_eap.html @@ -10,7 +10,7 @@

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

@@ -41,17 +41,17 @@

Kind regards,
IFRC-DREF AA Team
- You can access your GO account and check the progress of your EAP here. + You can access your GO account and check the progress of your EAP here.

Attached documents:
- Review checklist uploaded by IFRC + Review checklist uploaded by IFRC: {{ review_checklist_file }}

-{% include "design/foot1.html" %} +{% include "design/foot1.html" %} \ No newline at end of file diff --git a/notifications/templates/email/eap/pending_pfa.html b/notifications/templates/email/eap/pending_pfa.html index 0ae1ec304..13019a151 100644 --- a/notifications/templates/email/eap/pending_pfa.html +++ b/notifications/templates/email/eap/pending_pfa.html @@ -21,7 +21,18 @@ If you have any questions on the process or the next steps, please don’t hesitate to reach out to DREF.anticipatorypillar@ifrc.org.

- + +

+ Attached documents:
+ Export Pdf:
+ Validated budget file: + {% if validated_budget_file %} + {{ validated_budget_file }} + {% else %} + N/A + {% endif %} +

+

Congratulations again and warm wishes,
IFRC-DREF AA Team
@@ -30,4 +41,4 @@ -{% include "design/foot1.html" %} +{% include "design/foot1.html" %} \ No newline at end of file diff --git a/notifications/templates/email/eap/re-submission.html b/notifications/templates/email/eap/re-submission.html index dda0ab01b..a6a9e0b7c 100644 --- a/notifications/templates/email/eap/re-submission.html +++ b/notifications/templates/email/eap/re-submission.html @@ -6,7 +6,7 @@

Dear colleagues,

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

@@ -41,7 +41,7 @@ People targeted - {{ people_targated }} + {{ people_targeted }} @@ -82,8 +82,13 @@

Attachments:

Revised EAP PDF with modifications in tracked changes:

-

Revised EAP budget:

-

Review checklist with National Society response to comments:

+

Revised EAP budget: {{ budget_file }}

+

Review checklist with National Society response to comments: + {% if updated_checklist_file %} + {{ updated_checklist_file }}" + {% else %} + N/A + {% endif %}

@@ -97,4 +102,4 @@ -{% include "design/foot1.html" %} +{% include "design/foot1.html" %} \ No newline at end of file diff --git a/notifications/templates/email/eap/submission.html b/notifications/templates/email/eap/submission.html index 28fc68800..14a9a6063 100644 --- a/notifications/templates/email/eap/submission.html +++ b/notifications/templates/email/eap/submission.html @@ -20,7 +20,7 @@ Type of EAP - {{ eap_type_display|default:"Not Sure" }} + {{ eap_type_display }} Hazard @@ -28,7 +28,7 @@ People targeted - 100 + {{ people_targeted }} Total Budget @@ -59,8 +59,8 @@

Attached documents:
- Export Pdf:
- budget file: + Export Pdf:
+ budget file: {{ budget_file }}

@@ -75,4 +75,4 @@ -{% include "design/foot1.html" %} +{% include "design/foot1.html" %} \ No newline at end of file diff --git a/notifications/templates/email/eap/technically_validated_eap.html b/notifications/templates/email/eap/technically_validated_eap.html index fa9cd7d38..e395e0209 100644 --- a/notifications/templates/email/eap/technically_validated_eap.html +++ b/notifications/templates/email/eap/technically_validated_eap.html @@ -28,13 +28,13 @@

Congratulations again and warm wishes,
IFRC-DREF AA Team
- You can access your GO account and check the progress of your EAP here. + You can access your GO account and check the progress of your EAP here.

Attachments:

- Review Checklist + Review Checklist {{ review_checklist_file }}

From 4cae39a4acf9ff2de651cf65717a5058356a9725 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 19 Dec 2025 14:03:30 +0545 Subject: [PATCH 49/53] chore(assets): Update assets commit reference --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index db4b64c17..a94f24cc6 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit db4b64c173cbc3ff25e80c7a4b35d171271b091e +Subproject commit a94f24cc6463b5f6b4ae568fc355c3550b226deb From 2b550a00a7d0bf8800221e262a3c97258f682f1c Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Sat, 20 Dec 2025 17:59:55 +0545 Subject: [PATCH 50/53] feat(eap): Add dead line field on registration model and eap submission reminder script --- deploy/helm/ifrcgo-helm/values.yaml | 2 ++ .../commands/eap_submission_reminder.py | 34 +++++++++++++++++++ .../0014_eapregistration_dead_line.py | 18 ++++++++++ eap/models.py | 8 +++++ eap/serializers.py | 25 ++++++++++++++ eap/tasks.py | 2 +- eap/utils.py | 1 + main/sentry.py | 1 + .../eap/feedback_to_national_society.html | 2 +- .../email/eap/feedback_to_revised_eap.html | 2 +- .../templates/email/eap/reminder.html | 2 +- 11 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 eap/management/commands/eap_submission_reminder.py create mode 100644 eap/migrations/0014_eapregistration_dead_line.py diff --git a/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index af3d3c130..a7c07185d 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/management/commands/eap_submission_reminder.py b/eap/management/commands/eap_submission_reminder.py new file mode 100644 index 000000000..9064975d0 --- /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 10 days before deadline" + + @monitor(monitor_slug=SentryMonitor.EAP_SUBMISSION_REMINDER) + def handle(self, *args, **options): + """ + Finds EAP-registrations whose submission deadline is exactly 10 days from today + and sends reminder emails for each matching registration. + """ + target_date = timezone.now().date() + timedelta(days=10) + queryset = EAPRegistration.objects.filter( + dead_line=target_date, + ) + + if not queryset.exists(): + self.stdout.write(self.style.NOTICE("No EAP registrations found for deadline reminder.")) + return + + for instance in queryset: + 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/0014_eapregistration_dead_line.py b/eap/migrations/0014_eapregistration_dead_line.py new file mode 100644 index 000000000..9259cbfed --- /dev/null +++ b/eap/migrations/0014_eapregistration_dead_line.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-12-20 10:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0013_alter_eapregistration_national_society_contact_email_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='eapregistration', + name='dead_line', + field=models.DateField(blank=True, help_text='Date by which the EAP submission must be completed.', null=True, verbose_name='deadline'), + ), + ] diff --git a/eap/models.py b/eap/models.py index 99445c636..e439eb013 100644 --- a/eap/models.py +++ b/eap/models.py @@ -694,6 +694,14 @@ class EAPRegistration(EAPBaseModel): help_text=_("Timestamp when the EAP was activated."), ) + # EAP submission deadline + dead_line = models.DateField( + null=True, + blank=True, + verbose_name=_("deadline"), + help_text=_("Date by which the EAP submission must be completed."), + ) + # TYPING id: int national_society_id: int diff --git a/eap/serializers.py b/eap/serializers.py index b6d85f415..18ae06d37 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -1,4 +1,5 @@ import typing +from datetime import timedelta from django.contrib.auth.models import User from django.db import transaction @@ -193,6 +194,7 @@ class Meta: "modified_by", "latest_simplified_eap", "latest_full_eap", + "dead_line", ] def create(self, validated_data: dict[str, typing.Any]): @@ -960,11 +962,28 @@ 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 + + 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.dead_line = timezone.now().date() + timedelta(days=90) + updated_instance.save( + update_fields=[ + "dead_line", + ] + ) transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id)) + elif eap_count > 2: + updated_instance.dead_line = timezone.now().date() + timedelta(days=30) + updated_instance.save( + update_fields=[ + "dead_line", + ] + ) transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) elif (old_status, new_status) == ( @@ -976,6 +995,12 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.NS_ADDRESSING_COMMENTS, ): + updated_instance.dead_line = timezone.now().date() + timedelta(days=30) + updated_instance.save( + update_fields=[ + "dead_line", + ] + ) transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) elif (old_status, new_status) == ( diff --git a/eap/tasks.py b/eap/tasks.py index 819ae4ddd..ced8df331 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -422,7 +422,7 @@ def send_deadline_reminder_email(eap_registration_id: int): ) email_context = get_eap_email_context(instance) email_subject = f"[DREF {instance.get_eap_type_display()} SUBMISSION REMINDER] {instance.country} {instance.disaster_type}" - email_body = render_to_string("email/eap/approved.html", email_context) + email_body = render_to_string("email/eap/reminder.html", email_context) email_type = "Approved EAP" send_notification( subject=email_subject, diff --git a/eap/utils.py b/eap/utils.py index 83c4c1c76..cdffcb6ee 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -52,6 +52,7 @@ def get_eap_email_context(instance): "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"], + "dead_line": eap_registration_data["dead_line"], "frontend_url": settings.GO_WEB_URL, # "review_checklist_file":eap_registration_data["review_checklist_file"], # "validated_budget_file":eap_registration_data["validated_budget_file"], diff --git a/main/sentry.py b/main/sentry.py index e149e4953..b74e2d6ab 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 * * *" # NOTE: Disable local unit email notification for now 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/notifications/templates/email/eap/feedback_to_national_society.html b/notifications/templates/email/eap/feedback_to_national_society.html index 1e60ac8b3..30dfcbdfd 100644 --- a/notifications/templates/email/eap/feedback_to_national_society.html +++ b/notifications/templates/email/eap/feedback_to_national_society.html @@ -26,7 +26,7 @@

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). + of the EAP no later than {{ dead_line }} (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.

diff --git a/notifications/templates/email/eap/feedback_to_revised_eap.html b/notifications/templates/email/eap/feedback_to_revised_eap.html index 0dbd98177..c3ba19a56 100644 --- a/notifications/templates/email/eap/feedback_to_revised_eap.html +++ b/notifications/templates/email/eap/feedback_to_revised_eap.html @@ -31,7 +31,7 @@

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). + {{ dead_line }} (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. diff --git a/notifications/templates/email/eap/reminder.html b/notifications/templates/email/eap/reminder.html index 89f2fd3b8..661558eab 100644 --- a/notifications/templates/email/eap/reminder.html +++ b/notifications/templates/email/eap/reminder.html @@ -9,7 +9,7 @@

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

From 2968d874c936ea1bc130ceadfa03a4fb75b14cd0 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Sun, 21 Dec 2025 17:06:24 +0545 Subject: [PATCH 51/53] fix(eap): Change reminder target from 10 days to 1 week --- assets | 2 +- eap/management/commands/eap_submission_reminder.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets b/assets index a94f24cc6..8e77a01c5 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit a94f24cc6463b5f6b4ae568fc355c3550b226deb +Subproject commit 8e77a01c5183c75f07bd0606ed09ab4958a1c279 diff --git a/eap/management/commands/eap_submission_reminder.py b/eap/management/commands/eap_submission_reminder.py index 9064975d0..cd088ae79 100644 --- a/eap/management/commands/eap_submission_reminder.py +++ b/eap/management/commands/eap_submission_reminder.py @@ -10,15 +10,15 @@ class Command(BaseCommand): - help = "Send EAP submission reminder emails 10 days before deadline" + 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 10 days from today + 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(days=10) + target_date = timezone.now().date() + timedelta(weeks=1) queryset = EAPRegistration.objects.filter( dead_line=target_date, ) From 80f20bd264d9d6c7ae6d76bd42ef8c11ef651d54 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Wed, 24 Dec 2025 12:04:31 +0545 Subject: [PATCH 52/53] chore(eap): Rename dead_line field to deadline and add deadline reminder sent timestamp --- assets | 2 +- .../commands/eap_submission_reminder.py | 2 +- ...=> 0014_eapregistration_deadline_and_more.py} | 9 +++++++-- eap/models.py | 9 ++++++++- eap/serializers.py | 14 +++++++------- eap/tasks.py | 16 ++++++++++------ eap/utils.py | 3 +-- .../email/eap/feedback_to_national_society.html | 2 +- .../email/eap/feedback_to_revised_eap.html | 2 +- notifications/templates/email/eap/reminder.html | 2 +- 10 files changed, 38 insertions(+), 23 deletions(-) rename eap/migrations/{0014_eapregistration_dead_line.py => 0014_eapregistration_deadline_and_more.py} (54%) diff --git a/assets b/assets index 8e77a01c5..45bd2a808 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 8e77a01c5183c75f07bd0606ed09ab4958a1c279 +Subproject commit 45bd2a808cca8de08e7908975ff06029bf096532 diff --git a/eap/management/commands/eap_submission_reminder.py b/eap/management/commands/eap_submission_reminder.py index cd088ae79..d24e68122 100644 --- a/eap/management/commands/eap_submission_reminder.py +++ b/eap/management/commands/eap_submission_reminder.py @@ -20,7 +20,7 @@ def handle(self, *args, **options): """ target_date = timezone.now().date() + timedelta(weeks=1) queryset = EAPRegistration.objects.filter( - dead_line=target_date, + deadline=target_date, ) if not queryset.exists(): diff --git a/eap/migrations/0014_eapregistration_dead_line.py b/eap/migrations/0014_eapregistration_deadline_and_more.py similarity index 54% rename from eap/migrations/0014_eapregistration_dead_line.py rename to eap/migrations/0014_eapregistration_deadline_and_more.py index 9259cbfed..6b2d72bbb 100644 --- a/eap/migrations/0014_eapregistration_dead_line.py +++ b/eap/migrations/0014_eapregistration_deadline_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2025-12-20 10:06 +# Generated by Django 4.2.26 on 2025-12-24 05:46 from django.db import migrations, models @@ -12,7 +12,12 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='eapregistration', - name='dead_line', + 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 e439eb013..d5fd75dcc 100644 --- a/eap/models.py +++ b/eap/models.py @@ -695,13 +695,20 @@ class EAPRegistration(EAPBaseModel): ) # EAP submission deadline - dead_line = models.DateField( + 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 18ae06d37..9e193928f 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -194,7 +194,7 @@ class Meta: "modified_by", "latest_simplified_eap", "latest_full_eap", - "dead_line", + "deadline", ] def create(self, validated_data: dict[str, typing.Any]): @@ -969,19 +969,19 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any """ if eap_count == 2: - updated_instance.dead_line = timezone.now().date() + timedelta(days=90) + updated_instance.deadline = timezone.now().date() + timedelta(days=90) updated_instance.save( update_fields=[ - "dead_line", + "deadline", ] ) transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id)) elif eap_count > 2: - updated_instance.dead_line = timezone.now().date() + timedelta(days=30) + updated_instance.deadline = timezone.now().date() + timedelta(days=30) updated_instance.save( update_fields=[ - "dead_line", + "deadline", ] ) transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) @@ -995,10 +995,10 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.NS_ADDRESSING_COMMENTS, ): - updated_instance.dead_line = timezone.now().date() + timedelta(days=30) + updated_instance.deadline = timezone.now().date() + timedelta(days=30) updated_instance.save( update_fields=[ - "dead_line", + "deadline", ] ) transaction.on_commit(lambda: send_feedback_email_for_resubmitted_eap.delay(eap_registration_id)) diff --git a/eap/tasks.py b/eap/tasks.py index ced8df331..62384ad55 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -4,6 +4,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.template.loader import render_to_string +from django.utils import timezone from eap.models import EAPRegistration, EAPType, FullEAP, SimplifiedEAP from eap.utils import get_coordinator_emails_by_region, get_eap_email_context @@ -114,7 +115,7 @@ def send_feedback_email(eap_registration_id: int): ifrc_delegation_focal_point_email = latest_simplified_eap.ifrc_delegation_focal_point_email else: latest_full_eap = instance.latest_full_eap - partner_ns_email = latest_full_eap.partner_ns_name + partner_ns_email = latest_full_eap.partner_ns_email ifrc_delegation_focal_point_email = latest_full_eap.ifrc_delegation_focal_point_email regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -165,7 +166,7 @@ def send_eap_resubmission_email(eap_registration_id: int): latest_version = latest_simplified_eap.version else: latest_full_eap = instance.latest_full_eap - partner_ns_email = latest_full_eap.partner_ns_name + partner_ns_email = latest_full_eap.partner_ns_email latest_version = latest_full_eap.version regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -268,7 +269,7 @@ def send_technical_validation_email(eap_registration_id: int): partner_ns_email = latest_simplified_eap.partner_ns_email else: latest_full_eap = instance.latest_full_eap - partner_ns_email = latest_full_eap.partner_ns_name + partner_ns_email = latest_full_eap.partner_ns_email regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -312,7 +313,7 @@ def send_pending_pfa_email(eap_registration_id: int): partner_ns_email = latest_simplified_eap.partner_ns_email else: latest_full_eap = instance.latest_full_eap - partner_ns_email = latest_full_eap.partner_ns_name + partner_ns_email = latest_full_eap.partner_ns_email regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -357,7 +358,7 @@ def send_approved_email(eap_registration_id: int): email_context = "Simplified EAP" else: latest_full_eap = instance.latest_full_eap - partner_ns_email = latest_full_eap.partner_ns_name + partner_ns_email = latest_full_eap.partner_ns_email email_context = "Full EAP" regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -402,7 +403,7 @@ def send_deadline_reminder_email(eap_registration_id: int): partner_ns_email = latest_simplified_eap.partner_ns_email else: latest_full_eap = instance.latest_full_eap - partner_ns_email = latest_full_eap.partner_ns_name + partner_ns_email = latest_full_eap.partner_ns_email regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -431,4 +432,7 @@ def send_deadline_reminder_email(eap_registration_id: int): 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/utils.py b/eap/utils.py index cdffcb6ee..a4ddc07c4 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -34,7 +34,6 @@ def get_coordinator_emails_by_region(region: Region | None) -> list[str]: # TODO @sudip-khanal: Add files to email context after implementing file sending in email notification -# also include the deadline field once it added to the model. def get_eap_email_context(instance): @@ -52,7 +51,7 @@ def get_eap_email_context(instance): "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"], - "dead_line": eap_registration_data["dead_line"], + "deadline": eap_registration_data["deadline"], "frontend_url": settings.GO_WEB_URL, # "review_checklist_file":eap_registration_data["review_checklist_file"], # "validated_budget_file":eap_registration_data["validated_budget_file"], diff --git a/notifications/templates/email/eap/feedback_to_national_society.html b/notifications/templates/email/eap/feedback_to_national_society.html index 30dfcbdfd..393e71bf6 100644 --- a/notifications/templates/email/eap/feedback_to_national_society.html +++ b/notifications/templates/email/eap/feedback_to_national_society.html @@ -26,7 +26,7 @@

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 {{ dead_line }} (3 months). + 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.

diff --git a/notifications/templates/email/eap/feedback_to_revised_eap.html b/notifications/templates/email/eap/feedback_to_revised_eap.html index c3ba19a56..9bd6a614c 100644 --- a/notifications/templates/email/eap/feedback_to_revised_eap.html +++ b/notifications/templates/email/eap/feedback_to_revised_eap.html @@ -31,7 +31,7 @@

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 - {{ dead_line }} (1 month). + {{ 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. diff --git a/notifications/templates/email/eap/reminder.html b/notifications/templates/email/eap/reminder.html index 661558eab..47d635384 100644 --- a/notifications/templates/email/eap/reminder.html +++ b/notifications/templates/email/eap/reminder.html @@ -9,7 +9,7 @@

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

From a7b823a54f5adada823847ff582a319765bc8e14 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Mon, 29 Dec 2025 15:19:14 +0545 Subject: [PATCH 53/53] fix(eap): Use iterator in deadline reminder queryset --- eap/management/commands/eap_submission_reminder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eap/management/commands/eap_submission_reminder.py b/eap/management/commands/eap_submission_reminder.py index d24e68122..fa0a47e67 100644 --- a/eap/management/commands/eap_submission_reminder.py +++ b/eap/management/commands/eap_submission_reminder.py @@ -27,7 +27,7 @@ def handle(self, *args, **options): self.stdout.write(self.style.NOTICE("No EAP registrations found for deadline reminder.")) return - for instance in queryset: + 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)