From 6e0d11d006b2e4b0e1ea3b21934bf14461317dda Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Tue, 4 Nov 2025 14:25:55 +0545 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 8aa45fa0b76cb9e2e379c8a01f20ac48fff2440a Mon Sep 17 00:00:00 2001 From: Sudip Khanal <101724348+sudip-khanal@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:56:42 +0545 Subject: [PATCH 44/44] EAP: Add api to download template files (#2619) --- assets | 2 +- eap/serializers.py | 6 +- eap/test_views.py | 37 ++++++++++++ eap/views.py | 55 +++++++++++++++++- go-static/files/eap/budget_template.xlsm | Bin 0 -> 285349 bytes go-static/files/eap/forecasts_table.docx | Bin 0 -> 35886 bytes .../files/eap/theory_of_change_table.docx | Bin 0 -> 39171 bytes main/urls.py | 1 + 8 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 go-static/files/eap/budget_template.xlsm create mode 100644 go-static/files/eap/forecasts_table.docx create mode 100644 go-static/files/eap/theory_of_change_table.docx diff --git a/assets b/assets index 5f55b6735..6429ef4e6 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 5f55b673507e6323eb10a8a76f19552d73786c5a +Subproject commit 6429ef4e62f570e2e3e6b2c8ad81225f5fc0bcd2 diff --git a/eap/serializers.py b/eap/serializers.py index 01a9b5b89..7ed2d01e6 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -40,7 +40,7 @@ from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin from utils.file_check import validate_file_type -ALLOWED_FILE_EXTENTIONS: list[str] = ["pdf", "docx", "pptx", "xlsx"] +ALLOWED_FILE_EXTENTIONS: list[str] = ["pdf", "docx", "pptx", "xlsx", "xlsm"] class BaseEAPSerializer(serializers.ModelSerializer): @@ -218,6 +218,10 @@ class EAPFileInputSerializer(serializers.Serializer): file = serializers.ListField(child=serializers.FileField(required=True)) +class EAPGlobalFilesSerializer(serializers.Serializer): + url = serializers.URLField(read_only=True) + + class EAPFileSerializer(BaseEAPSerializer): id = serializers.IntegerField(required=False) file = serializers.FileField(required=True) diff --git a/eap/test_views.py b/eap/test_views.py index 97b1d0661..6844b5272 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -2308,3 +2308,40 @@ def test_snapshot_full_eap(self): orig_actors[0].description, snapshot_actors[0].description, ) + + +class EAPGlobalFileTestCase(APITestCase): + def setUp(self): + super().setUp() + self.url = "/api/v2/eap/global-files/" + + def test_get_template_files_invalid_param(self): + self.authenticate() + response = self.client.get(f"{self.url}invalid_type/") + self.assert_400(response) + self.assertIn("detail", response.data) + + def test_get_budget_template(self): + self.authenticate() + response = self.client.get(f"{self.url}budget_template/") + self.assert_200(response) + self.assertIn("url", response.data) + self.assertTrue(response.data["url"].endswith("files/eap/budget_template.xlsm")) + + def test_get_forecast_table_template(self): + self.authenticate() + response = self.client.get(f"{self.url}forecast_table/") + self.assert_200(response) + self.assertIn("url", response.data) + self.assertTrue(response.data["url"].endswith("files/eap/forecasts_table.docx")) + + def test_get_theory_of_change_template(self): + self.authenticate() + response = self.client.get(f"{self.url}theory_of_change_table/") + self.assert_200(response) + self.assertIn("url", response.data) + self.assertTrue(response.data["url"].endswith("files/eap/theory_of_change_table.docx")) + + def test_get_template_files_unauthenticated(self): + response = self.client.get(f"{self.url}budget_template/") + self.assert_401(response) diff --git a/eap/views.py b/eap/views.py index 6ca759a11..dcb13db58 100644 --- a/eap/views.py +++ b/eap/views.py @@ -1,7 +1,8 @@ # Create your views here. from django.db.models import Case, F, IntegerField, When from django.db.models.query import Prefetch, QuerySet -from drf_spectacular.utils import extend_schema +from django.templatetags.static import static +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import mixins, permissions, response, status, viewsets from rest_framework.decorators import action @@ -29,6 +30,7 @@ from eap.serializers import ( EAPFileInputSerializer, EAPFileSerializer, + EAPGlobalFilesSerializer, EAPRegistrationSerializer, EAPStatusSerializer, EAPValidatedBudgetFileSerializer, @@ -324,3 +326,54 @@ def multiple_file(self, request): file_serializer.save() return response.Response(file_serializer.data, status=status.HTTP_201_CREATED) return response.Response(file_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class EAPGlobalFilesViewSet( + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + + serializer_class = EAPGlobalFilesSerializer + permission_classes = permissions.IsAuthenticated, DenyGuestUserPermission + + lookup_field = "template_type" + lookup_url_kwarg = "template_type" + + template_map = { + "budget_template": "files/eap/budget_template.xlsm", + "forecast_table": "files/eap/forecasts_table.docx", + "theory_of_change_table": "files/eap/theory_of_change_table.docx", + } + + @extend_schema( + request=None, + responses=EAPGlobalFilesSerializer, + parameters=[ + OpenApiParameter( + name="template_type", + location=OpenApiParameter.PATH, + description="Type of EAP template to download", + required=True, + type=str, + enum=list(template_map.keys()), + ) + ], + ) + def retrieve(self, request, *args, **kwargs): + template_type = kwargs.get("template_type") + if not template_type: + return response.Response( + { + "detail": "Template file type not found.", + }, + status=400, + ) + if template_type not in self.template_map: + return response.Response( + { + "detail": f"Invalid template file type '{template_type}'.Please use one of the following values:{(self.template_map.keys())}." # noqa + }, + status=400, + ) + serializer = EAPGlobalFilesSerializer({"url": request.build_absolute_uri(static(self.template_map[template_type]))}) + return response.Response(serializer.data) diff --git a/go-static/files/eap/budget_template.xlsm b/go-static/files/eap/budget_template.xlsm new file mode 100644 index 0000000000000000000000000000000000000000..99eacf92be98f1d4278bdbe5b97af046562b1daa GIT binary patch literal 285349 zcmeFYg;!N=*Y>?h>5xvP8#dh`2uO)^!$!Kh8wsVmJEa@x6zK-(P^32@DgCYOb=~*# zz2A8LgZE>|78&O`)>`wN^EiHUZudI{1VnrgG6)3(0#SkddurqT;6b2uTo4EkgaWT8 zX76gG?&WCaV!-ZcXZx}M5uPy*1T6ahzy5EIz?im@%Q6_J)1&06cgyBkWRYFQ zK1gutV$qybXtpAbsFGTNR1W9tq&)$(k>r`lFGaszBdCvZ`=LRN4F8HMEg3k4Mer?0 zy>UjMDf6zg;M;(SF0AU=uR_c&%t;|mw zNNL4%7}Zq6Q_bag%vjnGodwAaaZ8F2_K_z{iV~-Zw5OUJhmQyywB&d6ONJY7b4y7} z)opr7+!&K=O&_C7^9a!=UTWDIP`jEp!DXz#_asx#hS3zZKHGIP=A&!M(q+$eI-9Y% zx;|vXKv{XaGl5N_8&_IyZZ)bVAyWls>)W^^z%;X=3o)Vh76Xb zk(dvj7Afu^J!EQh%2D}|sJ3KRc2KSgdoDhuF{%mh?^-w>^=os ze#)-Cd!J%rMW>zS@hEb=3$|{>_osXdSCih~y$n}_qUk#uowTzvxRGNf7i<2&hK&5W zy8Ly7LfR+@ZIkG8=tu(1N?5KHf>xZW)$^3bka*)ZgrU2m6G->bYrmW~NZFhor`-M^ zSRw@2?|txqTgv`wvk2GpE83xU_xCscxpU7gvOTYk-v2Bq9mls@`$-*GX6~Au z`r+qVOtnG9W^IE399e|OGDw+(f7aHVqSv-4_4xJlN=J-}U#`CLvPe44>pyxWN=ZCu zEPgpRj?YW_bC%Ok%6IA00*+Czu62)`Gj}QRmyI6%?;>eitU8!mOTA;tU)-F&d5O{z z+oKX&=0cTc8^2KQC34wJe!N4Sb|CtL8A*QDysCj%u+Nf4>ipc@f<6GKxThxs(7XRb zweSqP%;4cbpbbEj&;ivlayGMd;b4co{{L$Af3rsY&$Cx1C@COt;)I<9KT(Z!3a>Cz z&)9NE?{EDXX?cd5=qwWrebX02ZCid=amgLcyq&*zDHc)rvmk_>?kSMiO;&RtShQ8&NRT^WvXECuJD(WtXA@F1BNxG#@LSL%ax99pjH0{af zbTk4ZL?QTctQ8S6sd$QNP9MM!191IKR@m3kJ`Ks4_XvG&qR_$OMpI^8(h7a-j;Q7v znhH!~33L*xQ)z_Qbe|b%e{4ee#)-Q=^&atZh46mp7Q2^4)1RxN|8wzLLsBYbd#OVJ z-qOqD0`DoFnx>H`_;QxmRcgje=9qEv#{~2DC#8y8;mSuEV%0}tOt>cbYK)hj-Ji^% zwrT^3TY}NUrI>C=9r}iwAr{Ppp~SnPFQ$WEOHGFiLUW*(5}=`44)KAIAioDDNZ^nPO4+BTn2nR8?FX@tE3k*n7VH($57Fp`WUru zpoy6Dby{#7osp9r{g?C4;97j5uj4cDE&2mH%3gZ!+aoc`U#G=AX(r(^zTIT}ie4>1 zzM1J86&YIl{x?0kU=mw)1CI3tqY*?Vg+z&ej1A?jvmjb()UXQS4wsN*aKQe^`d93c z$ju@oOr|8|ZOw>jk-65i%@x(AkTTc1Y{`zH8n zzO9&r#0#6NH7z?SClT74n2I+-TpCuUg3b8a)ze>W$Iov^uup?8pBaZ~4HUd*9VCws znmQjY#rEuc2u_0gdZzN^QGCnr`>xmQuBwRPWIUI#b@D~}$cxZRUZp~)KcoB`BnQf? z7bl~!3p~t(9Rm0iVYcg?G#Bo@H#TYd)jj-p>u%u%RaUFdlF5YdyYKwQdEJWMq*Mqe z*tflL76H$n;xC-U!joCyVCO_+gl-psQc^sq*wAlEXnQJ$xIXWlA;b#oy+^42m@RF_ zG%qdiNnksrA~P*)yXYMruXO?QmppdwCV2>#CyqTOqQ%(CD}*4qwPbQ%O+WYjZp@$J64Eo@XUgjShyF!8yEv3P!>yHs$_$9JDRf*Qg{CTTBa{%d^R;(yINTYu zKm|(#b8mT%(I7nS!OJWKeN$xpK~dFRJ#A%FY@c03Zlj5x*vNpM?;TxGlbE^40eocCEc#6e}pRETCMI4XiKTe>)3 z6vuh^vcNu~2j$ky#zIalAkum!pIt3KsQkJUt042ne+pjb5SW)TK4LTe;~Ro3 z8u(p!qIP^Si+r*Y$yk%k4NXle^9P{S2Xpwt{qA}f^t*C?FI7{xxhY6Nk zb5SL`wmHXT?q!yIHkoIbExJode@=Y7g?Cp@^XoKONFWnd*h}5Wg2mEfT3_yL-tE2h zVS~#PR@8G-%N-PbiP7^&I-M)~AgCiTTx0gzi%v-q?$1@&8fCg2DO|N5d#1o_|CQHz z@9_S%E8p#2_U-J4i=1oqCcmJ8fzF0cCw&<%x$Hf^F^^L@YD#s2w(bwbjYeJsXKsgr zY~pK-^n`UWpNcmp*qJ|E?_)f=Yq$EIF=$z&65WYnEw2R8T#ge#Zy_v1XItQw-)rRc z@deAisV%?4A?#rd#>AI@Kr}xz1RJ~RSfzi7lGL+Lod5hqeC+5b{UZx*Kf;8$nVqT2 z{7su#MMAMn`LBbV1G18n8=4Qf?WeQ*y$V^=DZ3UQj_UY5@FqqgnEHS43$W(K-JtL_ z&fnx1gy63fM;Q4Ecr3fC@qS=NlqaO}|t#Hs2#L z1l&*6_)?zQyz5-+%yKYP7ryB1Z|wcL^{JvLfbSHoBUWy(x3@F~VJ#AS7Mo%)=Wce% z?Ol$H$l(z^*}bmwoAsU3U;jHzhUn?=lmRs9iv|Lbf>7W9Yy1Z&|KA4rAGm}AT47-A z|J`35DU%MtVEn-og)8y6b=iRpz1P?obn7YObcQxw5)5{7dTaM53s&$Mkiq;Pk~3PK z4`*H*&m5Zn{E&7;EKbMgjOe38lX#a7jvv@_IltwR4&r-FTNDTX-W5KY@O&_QNf`g@ zVz}#sJjEibAd!_u7RDHmw=Q~0&lbEhvB>WznBGxe%+=c799?#M{Jb?1l?Co7x0;Nbd@UuS%gb58@iXb_{=p zSC6Y3IYVyQrn;p{KftXXeG)B2&n)%u>jiqhwphnNiYSu3tP25UslkM7iE;G zB%3Y9g5tIVTPm%isqE`;W?WV&Q)6oYO?!!k?z&+2`q!M-D#XfR7K4Xsw)B4Kh$n*= zI#)j-!RIlq_Aw1_c0}jYZ^VI0czXK#^3~5EmRvUB*B#vT3(u_7wI31(m6OMC(vUWt zWYdPG583fM(o+ce+m#`+9}(hbjvwzL$1u(vU9JxbZmA25P3D50)j_<=@Av)g_0#3AU5*?q&@-rP&NaZuttvj+wbJGK!V{ReV*Ruvmb0!| z!ryfFbKkpk=Sv&cdHZP5Mf|o@CEtoC^&x~hHMeR;r0Z_GfAIRr^YS?SDeHy)pVg7C zCH^yC*>nAKa*`g4$hdntl>?|t4XZE4w1oZd7bNAS#*LPvr3~4J9OFyqCLy9p947W2 zjI6G17gKxrTD>9*A8tqu+jZ82eksjOKlbgh`7)dFc(3k9p7)WyMXlKLw@%Huk9fUB z>1RCLV3aow_WOOV=11bTo!fbGu$Rwy?N8O&Jwki?{u)8NPHzFKG-YejUB|&~#r?GA z(A<7xb}yUwcz-{_<=cGowL9Z%t~930vz&6hNy?u5Y=-JtI5`EE&@bR$6ldS4Jg{z5 zX=uOE(tE$Xemqh(J^#{enBKi;L-pyA{LJ|kbmK9M&Y6+*W+j#^CDbM5dJoyR>E(TC z17E<`8iNMC@D5bUUTX~=f!F||gzB+`{JWAGT07ltGe1W3`?)E0oXn;Ne>8=zJPdcJo4HN~2IGov>xZ#r|J(>PFAWAc-;db;~{xxIBQ?t2|jv!QH}`Ny&$TTCS8HId5XbUm-f-TYlZ z4wO(fA=CK4MO8)&d)(;2+w9TS5UmR32M$CQa_BW)I>_~!p>2NK;&;AW_I?V&icG-1 z*3QYf_pwOTz-z9JMJ0Mn8$SvClT`~$WPOA5?rABWp9dpv_86sRzE)24FJ+2PXi*JU zM%G9r+ys8Xwd&+I=tOPj3i#?lHJsh@;aMBySmCXQNO`ny2-a1H=uw+hwEjfP;I;VE z*3sFsi^tSh@yFBI_I8i%t~)0zgkx;NltezJ4N9(>-MZZ&sg&0ehh8P4WpcGQCvG~( z2vA~($v4f>jj`3MWXQJZm2PeQ%XMa3U7L;Z-m!+m8l#x-ny#B24uP398 z#~+zB>jA!ZTZx(1FT})Jz1;4vRwij?x}VOjN@K-+?{gix$K$6?47(mzc8Q+CiFWw^ z;NOkTZY5XyzlgrLR0fu8JlSN#M43I7oQyFB+?+2wU7ZKku3z|s>{7F5e zCaAcEs6(%D+{S_NzR6v%m|Q_EvTl z?!7kAdK#zUG~v2+l@<@gTbf0Cl7KBB;+y1_$^k0dH5rN>T7(;ERl-z?_T5+neGzpd zM*LKuRlXNg0COj#8N$i~(Y5SpoI?AqW_hE=Lr5B{s%+W1Az{f^p(hsRSCgI?GK+a?L&(D5gJ(+sWvGPY{wQ43L5?BI;gi7g4rZTufhe* z*GV;NT*$?q{IQ51Z!c48Vgnvh$%#G>(Ppnd#Z8Mpo&C=39;50D=)jAkCZ&+f5;)u# z9yCC%E`4_^sZ)?t)Gk|a{j5~*1~J!qVl<8X>m^P+GTzB5iUQqlcqk5J5Jbk^EKkz& zT!xif#~?}n$>v9L^dC)QQ7x%4`=+lI4)iAw_gnq3;W2OTUVh$yXO%=FME++9DG5Z^ z=#~e3bgx_u9l+^%hsIRJM@`Tyl7ei!v~@vI_tzfy=%(xbHFF7dqt4_$(aou5QF@`5 z>geXHeC@r3b85QB3ooWMlfZZeoWC0F(vU4wo2}?flJu z&faV8e^MqoD#%+hG79cXftLw{fEXdzDnCf8)})7wTjm?cbD0=a5)Bu6iLXm!BH`D( zp8yff&*nkaG{sOKbr@Uo|9v5EG~N(%<@TC1>W5FF+y}G^L*zv&*u$CIx~XuP*Yvcr zlEf|qtGM?It%S`pwNn*dP1vUe9qx1Ap^m1GX&DW_^R#p~Y0DR~m|+y+y+*D!Nf{nl zLs2N^|IhKs>|^$-drN_n*WYPR4uFM8h~79{337d(l41&WAPG|D~SK|H! z{`%_8Q#W0RMY2?kw*nA+91LIU;HNJJvYA{>vpJry`Yo6M>QBU7;)#}Ll8iexG{uXy zF)Dtl=a8k5D>3E2Ra+I!U7Jw!MkfA4Zy}-ZapM(`9j|r&xK!d1~y$v`$6Bp*ozAZ2mQUGyklS~9(nLS zVtDpim9d-9@bKqOJaY3*Z?y3g$+*olc7Xs@VyBurv1={^f`UJDhKvz9J1K+Fwd%n% zJy>w=7(lp^HgOc`1Q69wu|hBPv0RG7Xa&BPH8LnV4&5^|2>cOLm9bs>MwZsjd(Hp- z3jQ7a{vBDM<@tqJ&nr)6;7(}3*_)se;3mot!~49;izE48P8K@{BRn8y?)G;Cxe$dW zywhLZ58a7`1&a_L6Ga)Fjgb@-8v=wECU8iHOpWZ)Kvh_yADK=|v~|tXk`Kwf&)z(h z#8X9m#`PK~x_-D=irv`vfv-#1R8_@3S=>Bz2K_)#70nOsgbbL^ zbih!N%dUf}@ zc?Zlk9^=}WRKRh4IwuoEs$zE|hhOLH`4;8mkH8n$))Um6D%5TKq-~E`q2-Toew4GZ z8FaYKzlXl<*c4p`?z|jLA+sIxVXx-?)TiO_vI8+XMe_7Qb*~QBZr|})gG4e}psdev zVHz6OR>_y1amLFe_ZjuYMoxkq`{=UG5Y0FByYMqAArR&Wlv=>K0M7{AXOsjJNHbP$ z<*;zuGxKoE^X3CtwOc||+k~REYd?uLo=TQSfy&0nkCUT24R4DmBZ)qO*)z+M0Sk~q zYmi9l$PeaQ%#iOTt1mk@$3iRorO&sKEoEWLLP|pklYo2ryM-oSRK7K6tmP=;`{z|q z19r&wZ>nkQG3MvghL&BcprbdILB%gc0EYr7 z7?}7MSu$KYW}qKk(Eu&lhrCGE&d%P%7yfzehrC6d}PBASO@_zY1~CNLHk#W2~{w{<2)^a^kLijo9Jt{@DH5Ihz7f1h)C-44{Jc_ z8^gVDcaI}&o$bADVp0|qZ>rSZ(7~nthZjC_UPhV$xgDpx0AxF!Bhq1O0pJzOe_&O? zHYtXqOQv9qH^=0eep#G+ z9{Jqaufgru)IbrSa*rUEdmz2)e9#)r@5q-Da7Teg`vPd{y!Zo3D`>T`u;Ie*Q zSZyMfVB7RG>M^GYRWenUS_8oF*;^+^VY{F7_AOxrKzG{49zC2K zp-QAhl&FxDyZ;)i+i${Ot#gDhQ*GqiMqA0#H{J@_86^NDCyWd6Qg>B&plD94kscy)wOmexMOQM2Gh=Bt(DaLK$=S(7{M$&?+7OBs!z=K zul1fU7iuF~)3b$sAENI0D>>4&$R|jEyPoW;&9jPnxtN-(+1;L^PX&Xs}(+a?58fi|k7qkc(9IoxneN z@05dJAJ`@x)9f0#YX9^lY@TH@=*E50<+SIrS^qXcaB}*a-Xgo8xwqBBH$qX1gywCy zX;orR^uAez{N3nH6?|Mz^i3-~1!K|FKKAusAQ@G@$~hRXeA7#7ZweP?;5lJ~Nx&%~ zte%0T@l(OhNI=%ds#d?`wznS5R5@S+3MJGa(>b(t#YK{Z)=2@*Dj&7vxg*RwaMK7$ z!P!K(irdoo-<_lrN%#peNQyJ2rfS4jkCu~IfOryQaA?6wYlJ|(h4d7L8Bql&r>=&G zX;@mJ3%nc!<}+Y@-oiON-N$P9{H8j5ca_%QH6iq}N=rt;ck`Ed`|{oYSREnh2#$J7 zL^Ew;!s;SaD@@AZ7!(orq~-$|0dNG$Cei1+#LfLFHDghN zev&7deM2wyWEb*1{zdVXKWObeTBnldUGU5CQ}9NB+YZ9q_Bp995*rUh=z_vtQdTdeC7c`sTMyKGGHFR;xe=8CGb7iwK+3-%$-W$YqIq02H&!&u)C?ZU+r& z)k{=7mps1$El+#9B5k8ni~(Gl75Z9 zx25pPuem%$wTq4N4%#-xx85SJ-uYY#eO8jyuVc=p>3?QR!^}qKDb>F_0g= zP?Z0ax~H&ztA0Z20@4Mq)s{;n8{0h(!+wD~0s zxq5W;SSnFBKEBGDLX%t7_)ExD-2d&l`ZaphTgq!=R6@pD%BIE`=QUv*Rux_B$fSXC z(De}}7%1bZbRCFF8!xe81VmEkNFYK{4G$m#fHQ33LCx3Fa~2h()dm063#%qa@sGUv zi$=yV+QNFLS#tQ*|7nGy`ZP`(+nI@5D|TYHzt;+dR&ZILIL>3}JJ5~E$UA-zsd`4> zPw0C*u>NvG=+(|^-nEw-%ehFYU2~Xc{}G94Oy}OYf4%{(#!TH+w~ssF$mv`<$?0rW z6z9cRr%v_K?2?|COgtmsk8PFyBj{mc=XHmUo?c|Z+bs zn^V63lWoYQ44P7~0l@|wwTNS^Q_FWZWT*3Qnyv?*Hcr7iz==H9eOC>lM8)i=_Fy;r zW>*OB4n8;vh0PAI&j}~isB@p6T_Ih;WJ2Xt7|>|C{@Jgi`8M}u2uSd_7h2ciz^Mi; zXjuZSrwZJE#twF@!3x8V6{oXRmh04js%LybfPqg2U^T!2M}kED`Xg|tEV?w2|9~KM zOA_?SPx71)4weIv60jVQj`Pael{hfc7%3DVaY^Fwp5!KFI3d%xEt}NipLCj`Zt?xs z8}k5f+!zL9+aG6~Ku@a7p6?1ehv}`H!JWXyO9GQ{{1QuNmZQxbI>`4Kf?Z@f9>; zXA}$8TxkOe{O@`QCIF*8N%aWnfZp&j3;%;Fyp9Cg2mr@_2f>cR=I?Rv!t#VEAbA64 zeOt}Tsk09TDvb_F8!$Epo&^~|6<$a7Zl|WAkAmHtiyr3El~v;orq8$kr{llDJiQRb z>IJ^<2AyrRv(LzYg1HX^Y&FHF4Z2w#T?+QFf&QstGn}AB)B1t_nSVC0q^9v7=>||e z2mUXfm%=1~hCT8sWt_Cdl( z)CyX3cBHq$vYfKHIvps?QK)lv{p6Tv{lVL!vcbc#thj^qo0Zecyb z*m{lSYqIQAL7baM2`%f>#&l0M zAZmCO&{iH_=}JCat)$c!x9)Ki`5YOq)G)lUiml*TWZx2s$ZLoywSi~uMsjXaOCbIX zn%ZC^Qc>Rb?=yP3rJtKIR)i~?Xtea{l!+KIh%}h-Q|$FW1$R<<1e=pTW=g8<_#x8! z73}4M6U9W+E61~&;LkewEU49>0q5e58$+>TsW42Pi9FoK2b<;0R zCi{MWp-A>VVwVk-r)Yr-KMu=tJ?PCXsx2zqO&}35Uf&#ND02Hjslt+^mq7B7=DxtP z)yg7Xzx1ZE=e$>dB&oADt^j^$S-~QTVg;e0}@Hu5gNy!XG;JL zui?CFCqsj_z8R@pu^%^2^KO$G?ENmnT7Fczw@iV0yAKG7i$3rsg9Y9#~d zxTnh=iTJ2-~%UdsmL6 zZap+OBI0AZ>27*qAgi#t#vTn-#^JJ-)w-U$0UFCle_8fHe?ye%LP_&`;^3bVz^1mp zn{Ffe=HgM1zdOX?(2ufzuEU`p=-zAi0zE@q>5?fB$vUVP*0JQoi1v+c-8j#*h1llN z0n3`Tcf_*c<=o@zg2Z&(!F-lYEat(hA*n$n*XLKOrgy|-XO88g?33cUN0jEaS?yTj zMT}G}lA9vSapv0mdr>r-tLh*2TT&DE4@5y$8<&t}528342zVbYDDW07$e`81EQy@a zLm1TOw(lPbn+D>fb#pWagv+E1t=6ChEj0U@46$Mb8C!q8`z`qkXL03)Tvj{-d|c}A z5FT8j$L`?o*QVd2r8YHlP1ibJ0av+GD(d})pBc>!8BS0A z`TDaI)qUbbda778N{;r!*r=73sij&C=h#DrHf>yX&0<^%tMJ_WIH*XP>25Eru;)uU zs3)CbiUE`9yLz9b5zKVRAx?|RQP`Nc_K{{Z6bNubJxgVR1+q*%dQU%2;2;V)+?jRw z4y|xt(kY(##{ApyVzZnj3^k6)s2G|h(Xn*_GWeR5IlknOn(C})3v~QK>#yx zTFfyaV)Yb#4IQQer{Tk+f0lx@J8z6kWlKTQ4X$w(qx4m(FX)_6I;?+1jy){nESlMJ zMM^>-9f~qtAHi;{&jaPM9EmTQBm#c{n++gHS7Z1<3V)+EPm5?1z8NVqo*yDgziVo5 zNHy4ugeAq9bckI2`53LR00wRQ5D{FWBm@bN!WTxbDc>F;@(RVc@5*!y$&<(agsA1&pP%9=F{E7^eOD78KSng%bpRW!yFk2WTiVs&aa4+?XpYQjLZ}eTi`@Up+ ze0^`7ns~b{+!dd~+OAk6!u{=~Fgs}0I=lc`_h^%GF9I9v)r8RC!Wyi@zi#T47LjRt zETDTtxezg|N^ZbaThW=}2Q@>YeW4 z?Lp8v>tknV!p5#b=e?M+ttIRnG((WflQb3kELqdA*vXa zJ=OK2c(!c9lqChww+nuiWH)3k-Md=tdB+HBUQLnrn8pVU2B4C8d#edfaGEIsLJ_fc z5OCh5$5MW6ZJc1C7|r(Z8!`)%&C7&Leg|ZJ@mPP+*NshH%1fBqMDcwpu=KF}&-sy!6?q_cHBrP}N1 zow{7B^i&zPpX+yPk)7l6hsJ6f;k$nmsklkuXPF#Xp|sYbk?rD};?6Qp#%@0S>}!a_ zlo!XD$W(?%EJ!gpLIgXI+#KJh@b#BI=%Lq8<%-M~pnzDGtR_v?hR)OOcy~J`+=F(5 zQhe9XuVi!wsV8YbiO@~Ty&^P;6pARE2z&HJH;Th8uc7|3qtk+|y^d?}K0 z=en11*|l@l;TagZM<7+zfe%)97mx%4WWhG+k(>g(K-zYA_hbt5HP5CkW9Dl2)~z@0 z(OHph6S{{*@-KyrnU(}m@W*pYDY3SomdkI5Lpgq+WyiIxEhrUXIxuwB?E=rwly*Y4(_u^iaAcDH8M4`dts5GPeS{_P@p5M|*G zjl|pXJV81Q#5p+vm{u`nCo%))DM!HV+&P}!{TeHXKiAr8HDJ`?_*+;0GbXQc56u?BcJ94?79~f;8D&P+qBfxID(m|qH@+6uw%$cqVjBoRa?WG^4 z&UtRFei_)->&0O)?sqbbK8U7DPSCccz(=|3z_}^n2FeLfvs8do72)C}VZX@lsb1bU zF#ryMVx(ZpGSyB!qb|N6@=l#d-8_$~g1vkUr$lT838Gt9FoF?SDU@UrcYwC2;pWH8 z{p*LimO)NHQJYLMNoF2be+>XL$fWfL1$`LC39whNSCv$2=tORy+!q9V*dDaMd)D^* zqLgS7-ybiSMs^;+$HHUkxW&s-DXL~$lr*uQF@nx?iM;TEtWp88%KT^yOn~iPDjK0g z6Z;-0?*sVAzZ<0gZlIrMdugFOm6hNQV)7~IskSkA5h)22oR;d8Q)9o_Sb;#UsLssn zj(o(!_Np=7RFqw2@NCwBrk1>_<3TTemNyuQm{L(vUm{T?h#)_bYbq;(Ypg!onZdHH zNZWhfbg1^hh2c+6lMUCOL$11$QXw)6X9hu3Wg^uMKAM!fqhU<#B*EX1=cRzKiKcx; zwfVrs0Ag~1kKDx>76;}Ij}N)XPZ*VuW?bSpY37pbbyHU zq1ohB(dak0w6Az1_#nZoz*nz+ye=zX>>|$asNMqvPHeG>ixK<;iCp7jt^x|{nJ`Ct zAG13vSZE3LGq*dOCpo+$ew^tu`T;!ngVj&)KGizJ&A%x;xD`9Mu>g&?QP-`*lxKlo zLRA>XYQptylJaO=MuH?zWDu>)`p+5w1?sta9J(Eg9*uZMxH(xW;s!GeTA(fLvku$KpWnCybt^BBb~ zDJpC=6_3)+XgeAQlj8+)G=wFj9b^DFsa`|TC~Q2 zwovc|S}&BfUBvcq3>{m|sc95Am^<~YB7 z3|Y#%ap<#;sC{U-$LY!Djq;RJ*0$!1KDY8D8?g8woMW4Qu~84;ygoeO&{y(!z0!Uy zWKI~~1LsK$e@YxgBY_n(oh1o@b3?Ix3b8j!nxLv3tM);$m8t$Bc%C{$by^+I!F6pT zx!HLadN)>pLrlz;2!F692uc*7F+zM9+5=*G#g^B+g}=y+n4L-a}^1>hqTwUY_BwC`U5tQxA1^u;y45(Q|CY&7O5HKHB}Qo9R{5#Ysk zo=ZqwD6pe3dY$$UglP#Une2tUNJ~0Z`xV`+u%Ft?jgt{AE?MK+o%sI{YfwbVN_@JQMTX;+ zCdo;U@nPIedokjn?Em<4pT*c2B`fos&AY+56erE9vwC{8_CX5byScou2e1>BBk-W9 z*&mY%lb)Z=e@kvLBOUCC=Y*$T!^G$*niwL68(QIpVhIrtVi)~!vRuERZyalL0?&*P z_udz})yH$zqesQR@d)A$F6j`Lo%jg03W&+V1?za63cv9fKvZe-A-8yKzNYPA+oO&V ztSn5^E;9ji9G!`zl^#L7!6lXLL!`L$`|R~HJ*I2Y$uA(z0;(gg+H%mB8Z0lZnY{$& z`{PBPAe}4x<@afKqBZT3EN{W!ne0HJ1@gc_Dv&IkoCid8Mp+`;MiZjEX1w@~2xkm@ z`CZtRe7`z=pe|Nk{N7v3Qr<)5=-EftF7%$F_MxqzmxH|!RdS5Dp8!$MQ!r~TA2%G# z&PRWkb}DKQ_ae90+I&dZ;1hQ_F&rS8;j7LPnH9jng0&EG5rRu^7xKA>41p#91^vxJ z{?Uu+Dva4BjFf%1*pTrd0wVd_K7r@!{PnUs{#7JU|Ff43j5x^O$XrchQPf^wAb{RS%+-)sy=(^*-sdmRi^6!`G&XQ}w{M6N zF7YSs-xdJ2-fbznS@tR#8sBPU6AR?&)!#hz`J1P}O0(4Tj%B!_fAOF4FaCQyPFpX0 zzBS=+qo7;sxUR=biI92MmCrIgO(9DI0ZKlNZwaFw8n?cnBmS5CYe-ld?8&1yfs8dc z2Dp_bmBz34Ml0kvIlDFqN}Y(XRtN3Kji-(s`Zbh?8vMgz?*N-;_hri>$UsZj{e>>w z!<=k2IpXdO4Ecwh#83>+R_IaO9jfFvj*H$(xztpR@Zcft;tRF*!g0$@bI+my#E%k| znZHpMwgW}ZEEXQpZ&8 za4@p6hg3w{bp0wpfW`&!rNd;rAUwUpWUNWdP$ikeWYvKT;AYt!wx%&af3{)j0`!LU zq6jpAo0$>)n@Rw4gQ@edWbhYVe7@C2cj5wOmh|E;rIEE_bl`R`6Qlsg5gquZv=5|M zA6bbT$l5+;x&%kNL{M7+z15~UbF_X&^B3{YVlNEZAhdA??;e9vI+mrkUjUOJM}XDY znV&#_=!}SmEb$`)I<;mB=#*Cb?DmUcq=!Z$$H(a6Jpl6gm-6lrU@cuY6_5hzkMZf` z2!($yU(|zoh zeMy&uT?YgD)FOfK9(FVCSTT;0gLfzvQsF%>&+?~yUIH@%PWU+NP~eq5vE1DA#c+XL(zKww(P4_&%>HxwgX zfzkALUSb!e&&MSLD0u$VC_7kYD#2{`Cd5VQq}t8FT>cSD&K=0chWJXc7fb zC42kqx1sI+sCfL<)h8QDAe?5YYKK6lO9Jdp{nRn+4YsSNRIHcsLm0;L^0PzuFr3Mn zmaQcWh#SgSzq-ikc1)pmQGBNp(>Lk5pbtuXwfwN_Dn&LfOJB)==$FhIwk5lxF#)Ak zs5Y{z2e50o?qtd~(ay|a6Yg1kyY+=`WvkJhZe_9$lH}`l9{v)CuzwB&_xxY4V+iem zS4FYK!uY}^zl)=7z95RoN3)ZmmU?lJ z>JU{PmLLF5x@m4J4Y?v&%Tz$q!uLbwYA~6Y>;Nq<)c_IzI6qd2jNDZ*!8p+K08!PG zXcq4&YIq@056kgi?_8{Iw)!l+gg~0kB1Eo8o|G!EFr&YclNLG$tSb^PIKaK@tfxoj76VQ5D^|>;?YY zYoS}3J|p^Is+INO`F_FVgo9geZM1_rfRxqlr3O(I!2Z>mD(15agU$D_8xBK)jh|pw zUV|rH>aBgbM6fF{T&+!JFzN26k0DZ5c^pwMeDq#lbZkJtXAp9{K&QPrc_Y)a` zndGK&llTO!wsT1)9_umM*hP2g3Wg%LRbLVWPU*?p3j~~~`3%7HVnkE4+91_nq?dHs zXp3&eUuzQ}twJ}4+h?dm_5<57Ii=IeG%rlb@#W=z;79ma7@0evAsvTw6~a?5SpY=8 zH6Be%S#;3kha9KIm@apf4il=jBhXr{Z(j%8DnEITv&g-79^U)F~741Uq_4%8dE z1LKQP;?ao@&q$;=6aNyoVTiL`Xc@N$`~kkavAe<1g+8hIF%(Q-mf9Twj9Gmn=B3}2 z)Lqw~n~}~Wm_56GHBgTWbjtv0*4q?tpra(3X?4JTGDB}@fVTa?Kijt+te*kSpAY!p zPvvP1^J)FLSZJf^5|WtoSH*gCU$np^NnHBT>LeM)C9_(GowxLhd@dH@Wi89u7fhN& zb|X9~{sw3R-#5b=JN?w)v|VNC;AguIS-29Of8fG|Z7%65RF&|P9jIRYlJcD?R5k1< zTe6NuE9r2bE1Icrik@&dMKYpmyYtfLjvIY+PrrnUm?N>4jgjzg12%h%u_6f+vNJ3E z!iV?q@b1yl5lb@{?}qx-=hoytnCQ|i55297^fBATj4_ibQ7jAuCYL1TAGzMsFZD9N zTvY_8e?V=~&4hSW@ek%39>K^wO;Z>f zP@p(&jyF?@P|%FE7BDC3uOzcD^h}GWfI0PA=`DUY?|H)i2kHL~PTK*C=Nignl}Ipv zYahXCuxI@{UUD9chb}LYRYYbji_yS_8Aw-QuBAuOC_t2W~o30@wL(FJj0Ase`jh4#`%v|x{cT?24*k9|?hnAVd zgA{*`k$5j>!_6GC=B;NFEV`s80j8Nf4!(4^a3u$3(Z0YeS{j%|1EfBa|4zb>5u>M( z9{G|C9(c|~vuAOpu<#siMQ(o5xR(B_C0%d)2Nc@IRk=8V{vDh;3cvYB4uHX@``#Gf zC4>0wzUMTi21SAtxT%;M86hWh{(ld{Pb99bEG$bKF3VY4|tbGpjOF*A$ z4=0EycRStN7}X;g5@y+LLLV)U!q z-^lN)B6^+$Ony*KZ;ZkiA-YVQ_HcjS`)UnzjqeIPp!S2Cm-fbVH(=tl(JxZ= zK+5TqlZ)QZb#31Q-iLp>?YsBeNFLVgQgz6wR8fVvDPC``*}Uk^U4Lx3W39>`ee(YM z@tL~QHT8<4S)$2qpR;o!<)=o{l&I?+RsZ2l-P$CL@n>%lL7;Sr97`?Th;M@CnJ=Tr>DQuUB>kg^`9gSP` zo4ZIlH@An&(ch=@{2TYj71u8|9{>DU5%=YO8vOSI8|^n&D;uTV9h0*YPvP_3Ir?J$ zE`$6vF}YJN39lc?S8halGPIF)UYRM4N%{VEReMXbzyi4y>DFCJ-3{a`Qhvu1zhZrg z!x;CfJ$Sb~3ST+qN^YZQC~Pp83wb=ehTs^V~mK zy?dqBuCA*0tzNr()ha{>@WLc-I3Qjnj1_szRs|;Oaws#Qxn9jz7T?JjAk>k~adpcM z8EBHnz9L-`KY1J%5FB7BPOX;vnhCH^uO}sIms%%)Z{H$rcDX-Z@2^H#>%H;)&j-cE zQY`IPn~{A!VakRb?BbM!uQE!{F=0rsA8{7H9Wr%z98aMbGv)}VG&lzTkGroLDlIR&3WC;yLH&;a1vWtAWNFq;2>z;@W?}F+-+3d2=6;; z;C;~B?C~$GH{y5N_H$iL3~C)q)$?oql$)RoIKM2=yw;a~{#04(!e^?ouwSp{y7Qyu z^C~SGl@vXt>MXA7on=-{e5S4`P0vkP@O(w*8Z7VWlMcFxtE#m!S_LA@2Mx@OoE*PO zTRSZ3SlLcIytsqoS$LHq`4}z|_e(K16Yj@2@n}|@c<>@pkBRh5#!rnIE6NH}o2rWD z?Uct4|1jgToN`W@tjj0v4>2Y1m4+P~CN`w7EIH+u&6gkb&N;H?)P*?w#?P~kOvj)*r9d{I6xa@@PKkoOqpqx=gt=qOxPuCp56HB}a7k}Pn zSIjXJPCS;tOqD4ZowXlS(8pBRx`KSPKUEF$hqr%&Nd}9-n6>EEIgP0cAK{`2lU-)S;=Tp*dtF;g_~K2D1-vg! zG5sJYSHCR7TKw{`hTX@_d)sUiz3oX+R^)iz^kQn>bfcwVtFxvTvldmnm)rAUAlFCB z>pN^;A15GXMu88~9~aViCrg Ct~ez>RB2(i_#ly~|$`m%Tp|F{aZ3_Pua=^;%K1 zDM)CNuR4jBTIpH9ntmOZe*9hcKzshPt%Xq8dbqsGp8I_WyX{T$CE$Jq`PiFilj zcjrTjZQx$MGjK@Vwj77q$&L(vqfY^xRLG6bv5&eY`|z_(^?28`o* ztB}9tI64+9+w2`&Uf=BzJYl#xqF9{|_goIRMxhbj375P-oMa>4A%kELdAtoipI13J zAF^<$xUb$cB>69!I?mE@U!xFGxd0yCt}i;>EcbWQ_kHU;OwG7ex18qG7nRTBWYoZw z-5&P~dMg|A81v`H zzdFn|4`kwW>_v=3v0>s`D^D_d&kLfbv7PT-lx!wnnnH>A8kCvRrR(uHR}}gPn9g(J zQk{@qYeF;b_z?3{$lLnfm&J*M>soPhY}^!kA36$Hyo=J#%A>HQakqEZEn#jwMZm0$1S^D6U=^NLhD!^6?^M<~cu zmR9wd#~KCz%29aG#xz$ES+QDFcd8cKu(TxCHVy1kI4ZiZ%@q5IE0fd&03P;7J781I z`o>)>r-;t(q6KAt_bb~v=4GY*Z>B_oxuPGWYQUZ6W{a=G3M^^sWf%^CHx8}JPdfwU z+X^QyW;d5@K)v~jiJGxj%=|fm;-(~iYeVbXVBaaZ*t3rNLjC&e(j0gIAj2i*^rdeq zi+@N32_esA2Yy#Rs4+4(1XpNiF+alYqboiFGqQI&R{Bf%I;H8`*bD*Z<1(S(GC529 zri{G=bjrqU1-^+R4^0WlO+8ykQ0BSpZ`oLgK7wnqJ`x%7#@gAbd0!ez%!7Aliql;y zj?64=OOr*{*U8h?mGU&cj$mE(p8=;h5v{-QHdh@&GUMjdcITWU<{k&lvcu!UWlsV{ zB}K{ML5NB7gt|maTjS&GrpkTx;w%+{BA6Hz69Y|L0eF|xa&XP`MI%X?7x_?n^+>eU z(;zPj`QeI8&_4qJFj`Uur}BI9f~k}ZHFb>k{h>vqaPFWta-wTlM}u|B%FN!s`ZBt1yY6DdVRYUTGob{F6A<}Eg9)Mx zi4zbLQa}qx5Rd>r!=U64d`$pTMiBtt&h7#4{YJ|VilzHUmMB3S6Lj>PgY{T zKZ%(Pp>cYD7^(*w5dMz%&T5z(VnFt<0nG{mFDYN_7yDh(kdO$S$S{Oga3t7^W?F2j zRu~^S7ugf6L;UV1u$MVE>S!fSG(7oeP?!%L1{nbh9?8)SwJC@3cl>un!>LGtv>I{z zXuoJ)0#IsKY$l|cFGl^*$U>rUn2i4#5bN@UMhpZspTyC~;LzJPxdwZ`vika)*rk`) zm&Q2)Exl1(W%ZcWoIu04k}ZHdrpqS69T@z{f}eFt@*B9WakwcOj!1$O`5C7L`e6F<6i<%~6K!f?5HE zhsVMKFi!!xr+jj963F_Yho%G}1lNAUh4&%)}^`SVq)#ewWp`BoL~q!7OvE+xaY+~?cGDvbdO$zqJFC8B{IY$F$gO# z2wH zO(^t|2UyRw)tp*$+luDP=T6Z@cd#Bb9Y=N41P!O$v3`az2%Jf@nil%f7hnA(1RqX8 z>+H@&?EP(;OqrB^rk(l@^EJjH60kyL-S!;GGJ$|#|5MU4=}n2geC`GNO2 zW?y&+4x5nPVzxqgzb~t8Ed|XUs-$)E@P=ft%C=hmkT54~G&x3~+DqFr6A78F_wPA7 zW~uOL3@9wtV>NN%E;6EfIPO63GBZV_ikStuq=IL89k$ak0h~e9@WFkXv-ZRYs*1Oi zglB};QEzI^+$+K^g@VHp-WMGpMn;>1@WP!Kg9SYR!>{0}KkAO?Bf$lSsJt5~ADR$dUhwLK2BmzbbOF(Z{1gQ?M#&$?^5v>T+?Dz0v=td-!agTt%

)CuVFg zoJ9%@cyuXe0nkw(DCrSFrao2iNHW8?Te(A}<>U#*aLOx?+q52@m|__dS7+c3^epe$ zr3WOleC#|?{tGv*ZCZQ>Wv^Lh*iWYS_-|-^=bFqhPjgC2O?H{bU)^!5ht@VNw~|(` z?=OQ^i)Qy$jhUW5D4!E&PKP~Y$@O?4E8CX54^wN8>B)QFw6)o#1pJlV$`E>u~>j9G2Q{TmAHgs3Y6-BUtEwyZHuyW|A zOtcN5;8s=dS}|RMX2FhtPUwoG$3b%9<>^hqDcP*QFaa+KOqBHsy5|)@#><0 zf^~UBa`(5z_Tc>bgUAG+#1ywOVpdUAjTetgJ;qutN1~FH9%Q{Vgtp9VCnrRgd&4;Q z76g76CaX&&jG>kN6 zq#*XH7*GXLBQGqMA1RHTsaJwZxN-y3JWnwIKuCei1W5s6+Qido11rnS@HH9d81OZ%z%8@84GTAz+01jRLunDk9i#lBvM648$gh?gR%D|*xkSKA)yvGFm!1JDs zydV0SEl`Jw$P7Qt{0B6{;7B?=GWvg9!9E6Sg)I~+I*>@7XaQyuc#V>(L!uhnDI3fL zA~(XM0PO)>5p_lrJ^=TWe1kq@1SFQ^_p|JnzZwvbi!j6mrk9&22q)LegOw&d=0hpz z6^YB0JrtGu?DUtH=47h=As0OOB0})6MA=K*Y>(`hT@rN;CuEA&iYyY)XBA3-fH7nA zBNQqQJ@m*jn^+8a!(9O}b?L}E_#jr1VGt`KR^G`?d!9im;2iauUkMJLJX(y-FJ~L9 z7sgOcgjh?pG8N;8hIrgK1(9?rD&f?d*C2&rX_Xi;wz)EXrTn6Z$}jT;{ENuf6zHKZUpOci7?On4taZP{KssQ3${tR3rNW1kgS0qr1v-o{~Cvq2+Yh*RGWaH?Y! zO2!+BtOrxL)bn9Kxz}$(O50g+NtnMUs2oygpsf0lej3JO^LEIM=@Ooww;+SEMav#b zOKLSw7au>9StWZ;4pFdE`71eBFYWSxc5MgJrw3Dmvv*MR-5D!5sf0x%eIz<=Yq@hi}&N!#M(&HQz2{i``6&UCo(H?gC$ugCqi;m7!4Q18CgaR zfJ6KRlt%NgrI4cE9sg;qjrHnpkAAu|SPhW6;Y^PPZ|==E?ej|K)BC<4`hY+Bt5y^a z>Y^{*Ncjmr%JOMP^O|bY=^vKja1sp!(d)ct^7U-?1WTD!b;D2xl2Tp*`ItFYyU5;v zMO^GegDaPvo=N{x>Ovhs>j(pg(ELi~@2cBEhK0^{E^Ei_vp4dkVc`O( zfUXdTw9(K$4^qjBXM&;tYFtJ`>@S%+;Tt#FZvPleml9M=bHF#e#=+E(wPM!LN)J?x z1r4Jpq=1T@R3)KHN&PIvi98t0yGODBjK3W%7Y z`+sVF(2kP9g4j;lt$oE?sx;S%Ziv?$y^rbhiwy_i=yp#qeq4|74^IdX;IxBsV^i5w z&`A=N;4k26gcQ&@(vx(l^QS!$4~Y5=oiAt0*J1=mC*4SvGi0 zDIc|7JP#cRMgAZyV zZuqm!JCv*c$(v(|vqv__^&;i-$>l(+kOo6>`b?0rq_o@Jl2^_WAOwmyJ0DJ5d~c9! zkB$(n082Lu3G>p>=PJ&yxos3`krs441J>5EuqJsw)!Dv9|8I)*|1G{vsvCdiiTULV z+xh?T&f&*@cMdw%s|2XOFUimO)Yk@CKb%mDPGTF&lvUc)9-BwrB(ng04|MLr>UgkZ&DVavrZpT~R9C;q zOSKGTqiMkn$aJI=TvQLDr-4KBwU@Lsup-G{EzG>p{z_xkv-JK<1P%lp|P6LE)ie!%S}AI%&*NxtZh>2@?AyxM&*Dy+2jji*Ba_|*PN1<(XSF;M#b->#3u4+ zsx?i~6Q<*->UbdTQ8W$c&RDOCYKXTqN<9+r1MGj&<}K9X`5FwGl!LF}0xG@Rk_|ZX zjqtgP9yy=17B#cw#}kcKv7+}ds$`UMW(FmaMJPF~J>y=h!mF@A8I5_%xmF`jchk*a zJi!#6YdT^^(CdBm13<9**(ujq)rY$EHQLEG*eveWE_`HQ;=^Agy+Z3u6P9Q9H_iat+JFeLAjkV{Xv znOp|KvG@*SqC6|a?93&+5y@P)(6EQW$`?U)d^gQj$L3UfgZzKEa!$wGdW{wRy|eox zsOL^-HiKKV%9W5+KMF4-o=kLCzXmE)e=9sqf3~@L-J-6`3z0NNNH^xE*+Om!qNsC< zJEdZhJpz7^+N3qFWmD7jfs_5)+sg@G-f3P98{Ya`&9j^TXT#0g^$|V&nszy_S5y-l z#%fiQ*zWPHzQ@~}-*P6yZe9(am($xD=9>uVl@ir6SQo*RD(h~7@mPrmZ@9Jm%Q<_p zY!=_^?^b&8_u^88&zoaztSGvV_Lh#9_dgG})@Lbztj2eM_4>NJQXD>yrG^p(*5pY~ zS~A@L@xt4x`P=CJbx>EQwwGtC*R7kU-3Q>EOqMr2J%g0I3NK1bPIPy$O3q7`x83V? z_SQ71yzR6sN-Mr=W6i7crS+M~&E4_Z)%{iH6K1M+fck2BQhe zco@_AAZ*;5Z4qCjY$PUn@qR4HxNEcX_1XC4Vfwg@U20;p>vQ{$)2*$$=j|!5Y*}^B z@6SxV_1vJ-vb5Fd7m=6Ks;I}M&ga{QC_d}Y54Hy{eGa_a#4}N`<=|Yae#26S1LGHp zsK~-AcW&J-@r6IdZVN~Aql?3<(|mfFT`sp59u9V_hR8b!b%ZiOxVnnF8ScR@Z31ea z047yfUDxWvgk4K-_RmEP#i%&DJ#l1lSy9tPwI($TH9ZF1D@49%z+9C2Twcu!<8$XP z6`L~P-h8e_csjUiSzI222Al}uCy6e=OmH*P&3l0%TCt2wz3Q}LgXFzqV5v%BLuyP- z{GuxCoAcreshL_%L+W{EaW+wI4jeu!4EJ@MOjV`#rgE~1;Nn}!CQ6|G;MWu$znGe{GuJ2BD~xT#&&|hcw!Te66v(G0{Ybw4e6Sxk zyx$b{YST%I=>Z9qdL0mpgdQ!t@$Fw z7FS2}Y&+G_#&&)@Ie2gnC&l5&jb7LPuYY9Zs80pg(&WlAefYh($dFxCnru1&Z*Pol zNBn3)F$lBSv}%%h@$=rymDg2ZrEiM-pKxa|Mh6kuY&ydH{_OiT2K&&3Q(pb zUvJ~z03X19O>uGW!j;8+9KHB1!+SEXKp3_XHCoT{OomM7bNV|kAZ=U)+S=XT(auwI z^~pk`zV9~hS3_bsm}3}IO51q0x;2jr*J=L`jh`m!h*24izv>6ts|+$QD;TZIAQdHA zS%lIw6#?b2I_tlTj=dyX?3y>1RNLsO*lw8u(}=O8D(0mdLYJj0EgDp;9^4NP3>-$` ztuAmP`kGz86WaQvaf$5_SRH(gX<3G3OXFuc)Hg^NYMAgl0$OaI(V@W&;V+elIGdlYVM+ptX4jhPK&Op+QM;`k>+~1 zevyd1w#I=KWP8gXxC6%K962PgHU>&$Zx3a~D)j3ds9#^=S~)Ai8jfFDja+k5+Ofh( zhrPWc9p$AnWQ3!=r&CFJn*Ttnt7*foi7uJ_tp%DpEHNJ&4~li8WH=tX!nDmDTwnhk zTzU$?#jSA!*!4JNb8nG=%>xr5=Ie#ZiI`A#EMSG0u7h7ml|DXd#A+hO+0dWE8P<2< z7*{7`_kavbUAD)1no;+ky6NkG)qVJ@uIXQO)5n48vV!iWe7&+qKzHJBUx=-1fh*Xd zw|Rg|k4PBPolr%k^5}Z-auznX4D#CoJ{ETs4Mnq%xiFH)mAf`VH8<`qcJ3!*`d9BCjK}!JyZas2=TAv6D&S zMS`Y6 zNl55yoF0R5dY6d^P)w8bEm{ep?GejfO+M;zP2&c(HDQ=@KX9=WoP=zg8}jhM4jV?G z=y=SUGIPoCjdz#czhE8`yWJ3$mlHKFK9-sjtlNPCO}fhfc9^$t9&-TZ_t$e!6QD%I z%}u795fWFR<*f`+R`xZJx0VIA{5FVCk@kL`WpsMeevj2BRQTCc(8Q%A<(A3`d>xT( ziRJyJQt=YM!1=mMshKKTnem{xQSu-!Jb$U`eEl7AjM=>#+A5Uxtn_iq$+~g6ubgGURB(X_DkSi3hZY;IO>O%Sz~CA% zNaqQUX75?fAhts?z4aMNAMa*o$Z)i+u5LSl(X?Y#i??s^!}qDPWvWlALyK_XgQ}bk ze&xJ>4ByzC(VBkM>IH_^eI+u!I4}lmxUjB$1Nx!CnsSjbfRTlRWxH97e2C|S++HA> z&Wun-2k3w2V&0)aliPeX(PpHb>~T$Dg0xQe090U+;{zkrPpo6ICuo|M4H!hs2xdzvYal;boxOo`G*5(t4V55drw%yE|&J+o{V47bM@8$h$8pL9$CS7Aj5paOi4yHVt z7%es6-OSndfkafSQ$f>;i>Ui5@JAL+8-+rfAM<{v6~*Y<>Kgk88x32a$3RN45QF87 z23l@hwSJ3;fQd~A)(UJm-F-<%r(w=7ZooG!2mJAJ%I_76_NjvkdK5DH2W={PLnEQ7 zCiAx(BOsN~K_s-ewuFNIJdjWbi|-iGs{}gOF8LR`TZ?1_-jHJH_VGT|4>y9@;sa^( zmXQd&z=1q|KA4Q4k!7rNK*?({6Wpd)BU7S6bGrHPshMseg>>~QX%uoDf=Y`omsE}_!Vj+U8D01aDnt=HJDz%$}j$yW{{F%pb?m+Qtw|iO2#_2~) zli!xWr{>iSGKpR_g;I4&rrB}DQA%QUZOE3urRJ5d!zeV^C0>L-k_Q2y?bn_*u{|U( zptF&p13cR3HJ}oki+{&fP2oiwCIR8c@Ngjc}8eRgQcVK5TKj!fQ!&xVn(;EGURa;3~t3d$WGX4ZO0zsA8uQ# z2!Ffm^2C6ZYPJ-PxRoas0AiP}^Jst@C}4`K!zNrPKrUSIR)|v>xZnx9;&0uD__;bv z#QoPF1X>Wa$5GOzkyn{13P#Q~6K~2M(ZCq3$6d`2F<;Aypcxl<)aRz%DVHcQZqe4kpY{K5jm9kM z>6jM+Co~NXs?&C`tvg%CZVg|bo8*RGLOW)<+HLG>UY>Y1Hnc6eaxGqzXJ8W%30^aE zr8lPYp&9B$nLh&=YKKP*E`MGAg^KD05Gr#UpwJ<(3^(12};iB6=}s05*imDNZ2;x1bb)9y9Q?$h0BN^#}<=ZB%2qk0QyeLU54 zr^VFT+9RmT?w3*IQGA)3#J*x6QT=-{iczR|l-!DOVxl}uE=E^F z(s&$?>MkmLb}oBYLo#^a_dx7)=S{g3qpmyKZB;9pjVjS6a!Mt}q%W|ZbV5l@<_61< zP%}QG7|%3{2k&*~9P8qOti|8;0Z~cNEhEAA7nJ{X04eHUhuzPyJI$o(+L13EQj(A}v~1tIBl8`AY~}a0TwGFw9}7<2fOi36 zGZI>NZ*7=vs7X1YG$KwQ^PWQAT(?E+#G=#N)@-ODYi7HMLWx!57@WQXtz(O{yxh?B za%BeSRd|}FJX&0F@$_yx>J>%gXjygL-54DjYfAcko>HtuU+gPTI%>vyEB+1z%lqfU9 zR3U-2o6l&w!KEI`cc2>Gc3giO|7@a%g7oz`U?1_C@hb)LYDiqdfa5AtR6I5rTH@yE zc+Sn&lLp-^9J;IpR^d)bL?KdYLOnF&SPzyPxU2vlx)w?u7EPhJwfG9GiX1U3N!5sj zDWcJx+21tKpPha$&9S6`2{6=b3#uA|l`8APxuDtHj;xv9Vxi(47)GoqVv&z`38m0dVd&6*3H@HINkNs3F3|aAE#@bJ3V4$wvbE$#?79 zI(~pGHQC{MNz(lvfc>>dW)&i7H2S4uW{k}}*t zwoA8uWUFl9!aMb5&HNnOHGW{``W5qg#1{0=k#Cs@7Uv*5Y93@II*WC7Hh#-As9IPm zoBZG0g*vX8zs7$YRL8<5KQcIDp=nQLKczM8&uCzrQ0Ly|%M@+Z(zEPjl??}kicMnz zHU)5|VfurCb%S9WPgU;ERJv2T4)K0uovlAf>n?;u{u;qCWDg6Q_{bnDQu+qLw3b3f zYISYsR>vj*=6@43HgO0P;1qpr1Rez$I6;qz=Vl@!{Wn9~zek>7FK>;G=WZpYbj@GG z{(GrM(UCz#Fg@l*$(ABJWzowop{?aaE|-s@5I|TVtxa^q{5RjTvla#u7PZ+U*{tih zMoJO|YG!ZK08EE_BvGjx!=vdeRlhUURO+#{fGCL+#{ZuT-ETWaU5{3-2C5$tuGK&7Tq7gAUfLP3Mdn@gIq8Xg~ z8npoPQsi1h0^4a4Hf7~vG(r^t=0SqN2W$xyc|^O9gVE+P0oD3sXN&J)eOn70;CY&l z{k0k)@ycG}PG9uw>cAIDdTG>A=8fsbPLyR2tta@>JHzVsv^TH)IF+l0tx}5zJP6OlSnOiNLdzT|LgZ2W> z2DyZ8;x2ac8^-%ZwRP_bYn%>SBK;Laf8NLDXGN^C(Q5U-53gw^Cj_x}%{2bWi6_ro zA{pty8pgjGmFOlHHGKIZT-!TgLX#64wo^7^F(!<1olX)#AE>QR;8M%KtXbRQ&js=O z+iee-iTJr>+EgN>H|c`Y7mNyV`mq zcEow~BE*a!dcJWE)#AB#ujv&c)5OLJ^ZP@83L6LB(|M~QsuN|)9e{(6|Jfy}ewbt6 zxk6s29i;tjpI3*W!I*~9mW$L>6ne)TbhE;1MNnQe*UgNs3XWEQU$SwD$t zN98|x1}pg@&UXPs@R@?+!uS+kOrabz155zwY?H4+YSN(udop*(#YHTJAo zg;6__H;<^^ZFSmffTW)A_Uc2cUrR!#%A)ud_y?ZfuYH7J@lUF=jXi16wLAIi7HGro z%?w$G-EhF4l`V7LKP9SpHxTs-u5yI24rzMSRm}J8mkAZoQGaOX-{Ajuo4vRn5a6iL zzkE46`adq&F#oq?^Z$PF&o>YvWm;UV`O?w_o_5xWK=poGle8k4*9)#R6i$`WQb`~G z$;Y;~u1ltlrLcijzZvaD@Q}A?mnkNV8z+_M%T09ubeEN>#{3A290JIxJ;R6VJ_qu7 z&BZSRLV{(mirBLzz!e)V@OlT?T<9H!F%SV)smOp3H&`K6 zG*cVeWl?_fNF;E0mgKHV*CILQo(;cm$`x$uJ`&S}hQrua2ndqgJ!EWUs#L8O)fb-e ziU;RtUWRXEMGRJ-A|yR2v6`v_VskiWYX~vFpX=vTbO8F^aX&W zx}6_1uH-bQ2C8%{%U87o-lD*}&Y(O%v{4u!n(X*qzx@fg>ucNCfkX1fwlVpWw65ed z4sze%M=uYW3uIK|ItnvpYx<}t)uet^j)Az}GP8?avW!$@hr#_7sxo*>I9Zd1VN~-s zKK8S_I#antyGM=do5&^~&+u>6e~7`4k6MbEp4qYHn{Rk?g98wW(-&710 z6UANfMB*c1~qjUc5`7irwB|kY9dMS3ipV*f9>;?L_mxSuc z{#$N-aPJ=M(sdpICF!-H<^wIqWr{ru%Ytxav~`m>$vU;^8~8jnlNbQWxFz_-p2 zhZqTmI*>BqTm`-ojEDCl-xG^`h-6ssq5YxLxk#*w8-J0rw&%Qp+;|whF@XK#%tUDK zdb4Ta?EFYXUsbuT=E4ovje@L;Q!#T>UCBi7>)AfrLt4`a_ldfOUK{vnR-QaCB9(P= zq~9J`5mk2$aIkxV%25+^ftu(3!}NA{PlDxAR<*kRa+}-SM%&c0EHQ~Ukr>Zn0SS}4 zhzlBcHaE%;{d;=4n_AAR$(M(%F3Nj-le=Bk%RMQ*!#m|I(FA{TVzRDI4*_C>+S=yo z;%xD3w=FD77QuR?m+bTuq_Iou;$i`D)cw-1qm|lea}V0*2kJMZVo9k=72TI^35c2( zk}{={iHTzl+Qd&StIGYNF9xYaUvhG4=bk4Q(d(qVTrpuLEZPspw(@y9 zKPO#9?bM9xY;%`WoI5ox7s~E=_K=*~^~rTq=9=ya->OxV$k)S=I}r$i+@%yn{oUB8 z5S8$gEzI!0$Ih^LZ}MHWhXa4ta=HcEWz6!=Z@d<>k&Muiu9hrNosN%682%a9t|D78 zpa0bt+JE-7iv4ZQN?fI6@Tj$N>pg<(}{te`lxU zystiAysrwB_nR`|NttlD#};0TwDoOR(kwGl?=-@a7SHrB`}?3yaI`v)Is&W%)B zUgWoH+ycg_zT)}!X_6P>+ZTDx-@Nk@9#eL#G^&r#W_+|h08nZVXgE$#&F=_p0kW3! zw9|%a>$}EhD8hEmey)l2(Y`f56|rcyo=l^YxvBXon`BE~B>TrY;`Vh>`V z+dN3y01hI~V~0v1Fb4ORX*PzjnMCRuFIBm zua?}aMB9}9>IN}{)!nR2D86%bXF4DbjVT%1nO-ToAp<5_m)y=q*b;Tft01JSfIIZn zU#UIfL%Y)V;*^*}vqEZ^#n+y@HENAgws`UpZUd4x7!J#;%2b z=9M{Ta^;5QFGK14By9ya;5tYYBHT@>$l+D%CQW6f_9ZvuFOr52lJ&#D>g0es%5Wb9TR*>O)V0b7TWM?_|O z=|BP>+{;C7{Z)c5l2qKD_G;w)O~-&Ha^_nH0ZL0GP4wtBN_JeCb7WiWDA0buRwh?$ z*olYCkYhdxMnt7hWufj;uy#XsVl1@<4}I*rN71RcFhkad(PtoeAr3CYU5`i4Wa6pYQRb)(shM z|30eQZ)M{iB|C345qJc;eKd9@vk>TX$p)UVO8?5jl!a`981#i%!I$h1OpKl+2(#a)%OOQ! z4c!){d?M+zfX8nU5p+_9ZmA~34wQfHXj&0kQw`mS9j*;uLKwdAAsQLJh~pHJtEwN> z{{MXyq{F!mU4=HEqF^}PWl&7HT=?CZkZsQ2D~xjkxg|EGhgAJUqmz%-a7PO z-M$$}J)gSe)}%p6jp(JC|mS~f`M*Lwz2T`Uq)fad; zb%UO2F_r=zb8A|RB_5<^E&6E-&XPnHw^b?I^Qga`J~jmxCY3+9kW^BkM!%=OkyyzD zWK(f~!shBJ%9i8hrR1Xt9$KE zf@Fp_gxY0mjt-_WfT=G37Hel{J1Q*~)nGE#c8aV-TC{t|u#}p18Ku`t$*>he#9;7BW{BfljJ9GZ zdF(>O5b#=NehTn76k3f&c{lt)tYs>F5Ay07>uS$Z6J_#1T?o6 zwAP9+aJ=i1>N8|B%-9Vml9oham9vuENvyVG=HLEIW{)3x;pyxML)G*OCV^Y#o&xD2 zi57SSl0_1!C6Nq}D&kj!|3b!(Rl$9@g|TbuQd!$B^5SrsbRrOmn#Msx{b=zYUKRwq zD*qxi5&Z=?O2H(%9KFACOchlNpu~U0(g$eyIM-f^6P|zth|7M`5i&FqJpMHh5H^p0 z4FrVE<3CZw3!s6rrJ`Ri@Ky8O)&{DHmirN@LoX>we*Rj(QRU{872L-rM2T|na7h2( zMY6g2wFYYFPhj=CrcN=LNBa&f1@8hg^W@W8)gv?j8kl{)z^d|^cX3txF7VE6`` zDo{jn1RBzG=CKyNx>|);2LQ)tr6Etz3gkkoWkrfsAQM_?C{h5;X)9F%6k=&c;|&w6 z3GF?w*;V%-4!AgMA@%C^Qzx{guFsZt05Z|qUnYVP0Xkx=;=}c#P(I&`bz8MqnWFZH zbIlDs&~S(nbyr+kq2L5$LQ#_0ax=xUmCu*pH zR*pCuv`|PcAT@>MH;Rxv#qUy$W96L~Ke5ugArd@H7bD3i?VT8ZvC2DjdBlvI{;L+$ zP?hYW4NLQFwbm?iQeoVmg5}kk(6B(wqE~3^JVZIwHtWb<(8OIiRFn@)_oK3+O%{K=tq?SCMpqF) z)0CHwu`_HiH+dlm2xNFUY(BNA*i`{o6+&b?nQR*|PFRtGAV6Xi!`L0x=AmZO4n ze>4VMTK4gyQbcsGXHheQf}tu9BjXVL|Bzf+LNsNugF_=Bd2VXGu(AW+rerX6)i-LI z0Zk-@7G01#Q%Ef$s{cTPqA>jrG{|UwLBlMf%sV!2*0q6ZCs-On->EG7$hM3Y93T~p zhTcX4RiX`?F0@%dmuSl|v$oMdmjF^DmI_c6YmTtaLQ@5)h@DhomK*F;bz101smk^+^%n};qA7vo1wWS(T{cy7NE0tI>Ofnj7 z(kFpgBA%P%5(Cm3P&PxS5qKoWO4CUMQvo{ROcAD3-2vB$1zg4CY^U>*+sreSH`Zg5 z<9kRQQ9n%Crt$Xc#D8*~muEl>EE^xcPW%(}+Ds8yG*DB@0SwSxfKJ9O5QWCd@M7|G($a{H&8*x|`^2_#Y1@bssPLCtDVXZ9^h+hH6R$Pw!4Sv`XPdupQuD=Hc?S zQ(DR+s472ZDS@xmjQu~PRO&^Ulw1ui0f$gyu`OGtxlH;bk4V-lkz+u>OT_e$pX@Y{ zI{EmPW+;KYa^}<Q1_&4WZ-#I=$ce)6b#=wClNWEvU=7 zF<2JnMv|C|ljza^+_WxPR3Xsm<&UnTe4t}#ER2j;JSR3;xX})w;?jTNY_TXo#hv`Y z+DsFIDv{`UG=Jo5lRzYbrBgJkVi(|9ALi7m{|j*M*rw&Ji|X8zu;cdWGt?ex$9Y9= z5x5&*1q0GZn_w|YvT8Tn2)@2h6HN`ghjo!kA3+@A>0$EF#QU(R`>=Nc@s3}pDRy1q zf>74TLePJI?OT>`tmjxp^{3i82K>C<<((_NXhMuTW`NEm^bkPsiZejz-6VYD3-A`Z zat2efV>4z`MUpKLOn2avCPeU11O5kL`34Bz6JNgsAp$r3(5{hSx&`ML0fL9+zjMd` z&iNvfJyGuL38s5+jseB||DC$~?^F#r@Dz4IlXn{~_U|tKStsAFK3+V$ z;heWi*>OFVQCAIb)!|GwhPQXm_H4lUDpNym65r?J#g$X%>o-vQNJf-c;5Fyt)kRdM zy+6Kktt)yzYd*o?Nf-g*Dg8ff`3ky zswPF1{W4M^E3U17d0z{LUwxvckiEI=^1B!2seXoLzAyX^!HT6OkE0YEqOu&`VM-7`euhbRQ+k)o#45ct z5=Q#Q-a;lh%?TmTz+yKST z)w9@Pl!v<+u>D!bQqgkeUj+HvoV{RL>l338GahK$0#kS{2%?CsI-w-Jd>)I| zuKAa&Yl+|3G;=ns@6vUr{x8nn0XmPa3mc9av$5IOw$re&Z8nXa#%5#NR%5%d-PpGE z-DzJw@6&(%-?!GRH8baIU3>4@lg!+EPUYrV(9Oe1)^ZLXRc_uOvYboL7XLmA)5k4} z#V_XbRVMZQUNCwkE)*P$k?&M%x8&jLF+L!a5R%js1+I3UXvVX$~AaScP!=kf=+66e&@CFBL`pJPDcThN~bU{naTQ!$l(698g!zOgNpL8 zjvVw^hLTOHj~hZ4es+9t!zb@4c3+YI+ZGi$g;^7!jg|zcMDVe5-LXNgNOG%_v7+3v ztzbV{55f~#mh2i3rAI-S+~L7^P4{?y9X2@4QeGB1znfkBG|!a1Db>}F62vfN=ycH; zSt}gg8f|L@v_f6kQGaKj!9d+iG=fN_WMWNnOJ-#7`TCN!sqWnP78&kBCVMVQ@$`a> zg{C{sBGI2yJ_B@i>4FNTnx_-4IKKRMe8=7Tq`hfquVB$%4WH$IR90hSHLQfLiXiCZ z=q&a)rWj_}yiDK{(5%z*Oma0 zjK=f`5+EQ#GT=Wo6SMq&zHITOvAxkf>8@~l?wSEdL;<;}!} z{EqzRTiji6jXVlP5Zj>5{@s>co+NEtXEPp!qQpYRtp(a@QxW0O;!go>?%EOW4JpD) za1ye!F*)V^%Z;s8rEUdzi+toQfwSZ>DZj5slA_>-5|jge3nKC%!TM%5+g?P`X*+u`6OsRW3ro-iGg}WekAqxzQl5RI zXJ2~lJ+X&jDNq^mR|g6gxv>kG%C)%4hCs~5EyMl`*k@?;0miGJhZD!{nBiT{He3?R z%vgErBNu(`ZE1m>7i`I&EHd0<(GsJ0b>VrOnH?o{#W!a(V+GZ*TEJnDY>gEPmg!S= zU|aOADoM0V_7*eT@tb#|9)c^vPG;0T7YQD%ipQ_kg!n}2fp|c7;zO~1z4f_#Md%yZ zZg)CtIXRa*`tEIFibFIs+%FMCX15D};P^@Wdu|W=OqaDwzMVSA5vW+1$8?e4yox@n zqf**#ODP$&-@rcBsRW#ayFnu@LCHA$vuUcchhNi-jQ(qS^`|U7j=FUZ%Xx7RO<56L z9|8t0`oQ;cK9pw`HpVMp^gwsAI=67k2uD@B`JEU9fHmRS$CaxU)X!+W(WVJ9OR<3G z(4rQ_QT%NfF@PbM)%cOs!BO_fqR@Nue+alZ1NtYw$2#w=ltZ2>`a>=GJ_Bh-zD9K% z!gyt(uQOuu&?^x8*eI=rN{Rs0VnKtY#hxeW1c*a5v7kV5hFFLiuoZq{gJnD+K-64B z7;hE_@`)jquEUQPSd=4R54Oq5#TTBfk0fE5a?Ua0P6R>Q|MW zR5zzB0iWG&)}8moy1d+u9IOyWE60*~^3*o(&7q_*tsLz@X8;n zPMi6&Kj-T1>baM$V87%!GK`w%MjHKc`^nrx;oGUuSjdTh4CV4+`;mkC%FL;V~t<>o#)qlyKcI|K`AAzbYWhC=90}(UCK&lK6#z4kGfe5xt6{m;akoUOH z80v*cqOht==gm;34M!flP?K?)jjb+D3%sF9yVawuofJwl5gxxEpLBWuIn|mLB(EwK z43`wl!38DPmGEqgs` zTOZpBVddaw^FGB9b|t2lBI8tpAvuv*U1L_AA#97h+Z1(Z;|?W6+9n^f+F~akqskS>5m0PU^L!wk1Uf6q<>#>F zib<%1!Lu8a!^*poDWc+rgl!?DXNq3|>WVOT=h;)ge3#KIH1d#V@=#`a$uoY^7`YP} zxoejX_-^bC6K%b8lWCxgfmVR9Yc3uUr*I1<*~to1Ktp$TZ_DsFet}jKS)oDp+#Qn% zhpN@>vcl<;V>tb~G;)FeG(3-eef!G!V6k^vYX+fg9$AEtRd>_i$*%t))3)8g{$(m9 zZQcsM&%>~gmO+`U*h}rfG1s2ZA#(u76dazpF^%WRWbG4ya}21JEwNPq{IT5*6H<{> zWKxE~g!DMD4UlsmS(zvM5uYc6&!+$bW+U*GZ$alH>Zu*7;_VuxrFO&bLqA2kvgor$|l&L~+a)1tOAJX`}5*q|8+>Z{(3=p(B zKi;VEHb2p8{A^uvdkpI|yL;_*hZ3VrU!`efA6sc%s)@w~`JD0>U`@^R!E?-)$)zOoa(%AN4T6E}jy8ZKo$2u8uSuI=f zO;w~s)hN~o)U04sHNZ)dDuXEw6(^~3N7ihj?jgw!o21f16DlLb@ji4(Vl? zZa3T>@H-<&sWEpzP{!WcYE@^?rev;(7;00wR-q^1yx6Oqg>T96G&tqX(+C zDp_$Ftu~JC0$B&8BzW=L-hO7?RPRym{?jNI#D_hrm)R^0;(NVLtMIMBcRei8_-G_^ zVjik%n%#F*cvCDfglM{C7#EvfTH&B}O!k5Fy+J4L{7y&F$m6&_;i2t!@!`K_WO4B` zx!Q|fz;DL`WpqdC-rWy{n|X6U7xqVdk(ztxac zbCCiD8^j|9>(M(T$UG^Zck=Oa=AfoH$*!{)en+Se@)Cxz7_$07!MkoWW*uM$4bSo53#byHsxM4|0Y~;RvV> z=V(Eu2C^SDKi4-p>;YA%`lL5v^mg5 z*Op5nWW^j;gL8O4_kes$Ft!m9PBX}s328U_4p$1MBPuirZOh}9p3T;DZ}J@j)JIQ3 z9u0p1szRqj8k&oK8V*Rk1kf~Ui!}Q*SUA7#&!QJ>pH+G^V=shG4i#9pYJ(dIT*0_} zr#5Rs#a(Y!w~43uzo_!cWBq^|Vv_)E)VmipBVeP}ArIf^dq9XKt?s_~>H|?j4`j%1 z8YGm3$TlgD>w#2oO!VC3ob z`|N(ilirQChVSvcFc9W8!FGgW5ut|7o&xCO#dzogk;FY(MqNPZZ#&9`J{bkSn`Mdn zfcAM#Fz_oBrHC^=#FMXL0_SAAHX^%Hk2JpLO_D#K2W^f1+5>Bav zyVfqPAbGVYHgb86ynsZ+j}S{X=v{LAUidI>B3U2UKs|`O;z6D$R|K;aKhfaz?YPQ! zoa*5QU=1R{99fWyQWsWa;Z%9G1=#lZ+++1eU4N@6qOKy~<^Jx8v9S~lGw~HD14I2& zhbU1tsC$(s|3}C~F$%Jr9)Y266rkqP5PYYBk;+OFS9cR-no4=H3JA8FdrFRrlVUD=|k* zd8D5(!t(>zGkw%U;_oyLhOnk(47}b!pVjE}Xvv?WdG}bdGj&|cFdz+na z+Y|PbJcz_}WG#T z(?Cwnazi@|?MGMa0byEAs0H|T{0L{vvlb3h++)&btux+9mH1>TgD=Do>h-z9{-$e! zu@#slcetF;6&pu800Q>ONV~nsrZu9Ji9tz4bh;C|Dgj4f#U$y0hv_^zP6a<2rhHEaF(etQQoeq!dx;qXx3kxj!rr8a`Q zQD*lYwzDbpzCd0kPC^_kjjmARDooN<;`<7JPlt&^Q?kTf-YRmseC|Dj?GMQt?KuHC@^=-SrDTl~?QrAX@!hr#oowDZ2?LfPJN`VrEv4H*bB_|2Y!h#mu{A>I}ueh`@O7*YI~sU5Mb6q z`3~>|lx9$g#JwG(^?9M(+A-O~P*iw{s)L!s-q)F5bKr{b1X7rWM>k&6Ar_N+AwqX{ zCc3z|1GrVMSi(1aMI@)lc~$M1;MTic!=-94^d`D>*Afc0R0nQVVJAQUiw zKe5uTvGDYPCv_9(S3=PyTvZOBA(JA|gTN|Y07X_!e6!af-`1tWPMWO|bt24O$I6hP zRzgn?Zpwh>c2jkdFw~1l)B`pjiGhVtx4>hf{#NEPT)9?9%3#3bYyl`nknlOBJRBSSqDSrEj=Bx*78y*7#6JdcvM@?UD(^8q)0Di8| z6ZB>ThBSS*r}=24BV=G!hq)koG)yZY3OJo4$Up8WJpgUDZasE7FdKDfo7Cfd>m!YtAaRjoBI0WfVx3gU!!!LmNN_aUWvEDBf-*!8piEw}w5JI-xstXboYj>=n0$-@$2_ zins7+5{LUJ8_U|G0^B`9C>xIr8v6$P-wm=vpacH2VAodl()4w%!s};=#6-imO+5dB z2ivPg9EB;D%7UWCpY`KkmpsQge_Qa5tf4nn$nZ&zuMG{+nJM)>{*4h?DY7<&ojHsI zyqvK4Mbp0T92`~mPw|ed&upw5W+Woli%z3ns*mGI$V!0Kb*-}p(+UHm6@6wtha8+) zOIbqYv3T0hz(45XE}G0{5rDfM%7z3~kFqcv+~oMWIg`fYAaevCBm@!KDSHGz%bzl% z6tL5VYv_AQehJ?3qSw_U&|p;n2pyL%jsGb3$eL1r=y)`PJc}CBr zL%ChX5N{$_tn?Vo=lc|$#(wpRZhHId=tf8HGA`ja;{~b*bQs`}T!w8OhtAUd09c;e zycF|r(%b)cd0m2_^)U7gz!itZLK|NcK+tL;!hn_UB$N5Ng4|HPIgOzaJtqlt_(AvZ z-IfpxR1owW4E};l0as;2NEPbXPe<2hP?6Dme^azjGVC!ayh$Cy1^IBiF zGj}sEU@-r0bMQ^gpw(V+vPgR|h^fOap8)R?A;V+F1Nk=QT4%8w{W6r|_9y#i#0qa6 zg5=S&f7{lK2&Wa)6V?e`)ZNNZM^b@!CXA3o=4yH&Ojf)h41Ax6axt;1QRU+nWr!L{ z3@Zt@CpH?Y%v>UPZCRg2zKNLeGs1cLBcAO}>V^Hz;@_Yg>@2tcNmNbh{5o*{^jaEWJ3`UT|mQ|S*pDPV0 zm6OMywjFX2@w9pmjMY)P$0*u_0+vymzGYYY73b-cD7Bm26i-#v*#o!C)`B~`hW!Eox|6;kqvcT1k@S3csBQr-pV>!$`Bf^ zZ@(+OqYpw9L^#H$P(s!SM2R$?Qo$}xH5l(FB695#26I^s7JF?RmmCyp<$cLix7-9; zcL`#oDcF;*6TefqM~Oi$eN(qjY8Y{yZzV;REd4hqTtm0|{b0ER$WS~`T$i(3)yOO^ zSBA7fts+R=Cl7X-kC1Yjj(Uo8q{&eOp~@^ad8f*yf_}a$9AGqLifS$|^&}p(Jm_4%7!vU|Zr6S3KLx z{|uI(A8SFo-o!$+M73b>s&xjknCNh*8r5wtEf*Q(d!8A}HQf!QVNuAfEBYDNBw03V zgz4VMAOh_EIaY=ZwLSx-_d60ncmcMOwt!ee_8MpS+^{rTp$odB@SU#=;<9lfO4>Lm zZR-h{L-t^5A-J?+Bl666uta6b2wPwV>ICEdn6ecCiZ@2lS2X2Fh*`fzUlBte=PLpb zIxU6V_3FI7V`MrlhG~Um+R9jj1M}^U9ZD5YZDPuR8WyY?c5{4q7fjL#f_!UUzcUEE z&!F8%8HpuZ&2cZ@!PCK%cG+0$a@&AX>mrp_!N+j@69d)qTm$7s4z)`Me-!e~@p?gL z)L%$SWbWRhnj&}%vnd!pe9r)6MsudQmL#K4h`A==H4(VvjFxQnml5|+Vj>?68f_*G zS*drpk4V&{vIb!J20onnh=i@99RG$Gr7xdoHEr|zZd#6w;O z9fVBL)KRpo3k<==`OgInp!3~@8LO4^nTv;|>x;*iv!S_^g@lD;+ndhE>t;3`=eLI! zEB8mn;37NqD==1$txwz<&N}6%C*@XrRYacDJM|grZnvId{FCKX39PyGT&2*K+OB0< zaPh?kDnh6#QcQ@0B9+o=GV5!4I$EaA$o_|LR$(j#S`XKhU)-+aiN=ACYhxrOLD92+ zj?$bnZKAl-=X@IBVP~=54@pFg`4sN0VDP!<#HWwS8O*&IeJb>}Tbz+waua51GEVFu zUBC9^?EV8Xeb*OO-#m9cR#0UIIY>|Y99R4j4D)`l3C>FSvyTWz?=gfyX#^*H^yd;V zEH+Do@J(nO2nRKTz-fgW(}Xrm>NpdTcyYd-nKVq`H8ImQSXE~sD?1XrwsA_soRy{d@{_+b~&U|qLeN_xzsp7pUM}tv0!q7qHU-n3~6($O0tki7bJ6--?xJ6I;-G>h2~Y(8x4{O65G2n3lQpr!UbE zGT01E)~1{HcKd1B4`V1s5`4XGk{7Drx2FXU?#K?P$qNl721g%(*vPC)hnT3-fUw8t z0}iOitP6P&QJhSJLojEZ?MPI6^nC6WI>&p^NL5crGjtt1w$EwNx9nu2ghrk&(v3Ct z_Awgs(sTw`EjI5_jYT1hz!2{TgRz~+yOgpZR=iQ2evta(p-rhynP4$~dlBj*B!O96 zmX=|$6Jk7G`+%Jg4iRZXH`s++i>lDxAcRENBSMiRa(V;;vsQ8Wq&+*4RE4EGKx-fl zSll!$@ujLIDWVFC#*QS%H$2IAC701I_M`{b_B%>6@Dq@haNrk0`<*hV=;>9cF4@o5 z5E}DPv0r=(yKrRdeZ3kL3%PuUBh|Tl_grQdT6PV#(j8y&QqJIZJ%{zryJ&*Sv&x~1 zGgG;O)@rK|+Nn+Q@~0x<^d*B^Dg#*l*YD>+fyUhTFp`Q)?EXgb63gC50gw zP-9j*F)Dv_EXEX+FB@NrjYmU;mV%0;ujV?4DQ`!JTQh#7#(Zg6eF@b^*OCn(=9jGw zByYx1^G|yP){J}MlAhZ7+5BT{^3t7Cy5(LJ!6RCBHmc5Bpa{503f~~zaYK>X61iOZ z`BvRv=gSW%{*Jb94>71NKMA~G=J;dS`nWCIKRloJfdrOQPM#cgOG&`ftcmmoY$Sle ze-?pr>R^b6R}(&sHY%`vBAt#qfi_PNk;(V4p`~?;d%Y><{nJ zlrUa~r3#hyV@i5OnJ+XE24BL`%%()yO;IAb1u_CRjX^dnZ)QN@OJ0mbUj9(@_d?mE z7D&=adzgTM?$gGDt@8rEI(nB9aOD_L^{NAO1XFGmomFdzCEhkB?L?ZSIKDyt(U`XU zMDx=(SvzBEcT8=MB`Q}$XEIJc$go6j-~?|pA#v8DvIv}&FHMvr|P;qp;9)TNhA{r zyw=@Ip2F^db}dkHYT@s^fXXA-stb@Q(tfwq&bXl8{V49Emk zAEG&#Qj?glMk^O`&D_*fESpSgiesbQwPw_sDOSitIHPw>()Hg{N~Yv4xJzB&YHH|c zTIn3tV}8Xxb#l7oyE)yvPCz&%&B!)Xk!o%F#M}O8yCcP$dU^r39Cr*TY;!LK?W|Fr zx13|q`y(yq!LAG=E31w6Gwx|$!}!8JR!@_-xV?TWsmNj)`*PbS%escrJSC;Nsco@3 zU5Q5{7{NFt**GV&Wv};!7%hc^Mt#iDTQ*AQ;!DQc>gF%yQ!`@l*gzqd3@o`_TJ{-t_W0gxjeR zqcbFeK3Oc#Xy&8mY4j6Gio3KFE&Exdf%UL>8}_ZL7GG}Sfx#Wh<$Asg5V%YK2v&@S7gGrsWYLS>e$$;- zOzG^B)d0l^Qn&0Bc{J-yt$>;kF>L#xWevPZZY80;@~M6P;- zY8b35OK3KT0+m73u-I&1TtWD%{Ahm1VWz$mw+;d9K}6KCF&Rzjwtc%E5K&+yIKfo0 zQf?hW%zA{8JT7HWV*C5*7&eGu{(`Q|HXNXFPJjGIDDn+5L9YThB&@?1{JZpqa z9oH2KRwua-+`<_7x?|f-0paZ3C`XMCYp~Ha5x0v$oc6-F1-IIaiD#P`;q##F3K{7< z)CBCbz)L{_nTRCc1V$m*_G^%Q`7IU6JI~h%!M)Y}_?>~*jq{G|cSX~1=eVU$iQJuo z1PWylB3w-o7$8xTAA13s!LtNw5}@$Y{V~8)@}w=@rT`|otu;;WD=0Ww_t90W2Jx(! zs_0rn1;c^$hkG9R?~!)|86lX%=Ig4YGIt8Xf~cd5Td&1&J2$9)h!x1^SzEV7Eyhj# z`H5ib;(H*Bvc6ph-kJ4)1i7y;&)V}LhIXz+kGnG|?>yqe3)em{0=^~W*VnWguRsJg zziLr_O9J*UFD5UCK|$E$4eLp${t=u?=_x$A`_--U)#>FQ7x@)tRUDm@Ak3gF zRVl74kY;adKh5qp=W2InmBkiNvAfT#B$gW|`?33(D%O)rrXPN8y!?5gskaMRwhg=0JS3=75!sN_Be+5|=vK1%EgHs?bfnT1`CCu-6!rrPp*P&ut*^ z)#-)P8hf~}Uuu79eNewWwVdlK_ylMYY}az4VVAl*pdL6Uxuu6dfoaDt`Z&UdwO>Bs zY{z_|&Sc6)iBrty1wUgT(BIwka3timVo6MMf(>U>Bqp)9aX`ywkMC2-c3swk(zgg3 zVz^3y`*8(3=HYABxwRTuh8oPe(>n#@FV-!dE8mwr%~VW??^BM!)b|dAt6-OvVMnwn z(~(6aN?o)~Q<|{VcKE8l(_K_!9tAT*vspId7Mj=0=ZZ|WP*jU(vCI^Itto`ARWGX+ zDQ4C9X0mWCj@Br_GNPna>op7Z4HylRdr@zm+vXG)NYyT5x4_Lb!f>p1Tp_ZVY>Ef) zJ*1bRO?m>ShvyxxHVBV(s&c%fkoDy94G{+S>zp68f>X6LulS@=465N)jzbQMFBN6Q zPxa_lI*F5F(Zn+PmgO_qgWFk)aEBy;#-Cm#fZ+&h>+DhJ?2Z1Ih3clf#Y@r8O;zVo zH#nJx;*uZ}I_V?g^=QG9OAfAOb;wS?Pr#feEfh!v(djwq^PsSVWQ5;Qo2K2(Za>@+ zjKXAX&>Iw-$KF$ex?1f{UK#Vl&Ud#eQb(53j=IeZh{2vHF&T4T_!gCio~ZW>SIY+U zJry%^SqChQMq~!-xei0PPVEK6V^AkA>GSdC8f9cHoshL0;REiABZKU-=1jd-5Y{XH z-}T5Uc_&&CzXt+3Ec|1SEbFh|FvSdfS)xY_IDPj34|b|fZoxtg5e}Erh$0a4-6T2t zGf{S7wEgFty=D`!3Vzxzuxi9MJyvKhOpQwe@U1qMeg_s(T^PhGOfx|qN}CD0BC`61 z)W_J1Md@?SV{mOL9?y&ml+Wg~sTyE@E7Q)F$Mg|I-LsnV$i?2bYi4or(_JOnBF5vX ztR68(*(4E{V)7H4pn9cNdWChUVPmLEVk=9a!j}>1KUPb7b)}gHzI2Kg>#zxR#Nq%| zp`=y1A@qt>pLZ9lB-3&gx{1mUj!SpPb$HrCZ_e=vNBfu=DOv~-hz6$e#p{SD2`?mF zRlTIplY}#SBA)=!6>iy4L>&!F&{j^6>D^=QEavrNPfXug)ry(i5=VpWKc|B2laKl` zj)v$)jfF0>6hjMEdpdKTbGl4J_Wbaag)%wY+KTrXn%tIhkRXOp2Zh)&7qPOy>CuY@ z0l_$<8Z`II**DCjdg(bH0{X5zZ*mO@L*Td@8ljgT^U*6?6Fq9jGQ2(NoLW-yHvDxf zVNJvCI_xJ9b)2h11Oz#?BgbU~6Pbu`XUjR}X%p5M)eSLkgP9TUDQO_4UFj2p}djRw-9v@yV zT~2L|;lI9|T@DF3-<>_|UgB45uJZPupX%WCy?Q<#ULW_LdOn_C-tN}_+iTqL`DCTIUfw@nG;I*sad)=QJ8Td0;@FjE!>4s?Fu)qZL`rxO|Ebm z50@n^1fDb79XuLe>o{U$!~<$D23{(VVo@#mTcSJxB`biHM4%fuzC?axHbH#llT+1n#xs&@iJ(-mphHa%As%7f z3E~7l>c7R33VwiS_!aG!yV58$kaHv#_ z_YeS)tl33abCPr1D1VT=U8-2Y^X>YgW(87BL5f${0pD`$`h;^xtw`HSpfn-lx(_dx zi+pj%23n(n6+^}1wocnhs5C*L71A>uTxr6w3;9z7D90Fw*X*!?g9Vle?o}b+OA=Gs zqb`m#zKcP9Hcov%?^WH^8b`};k7Z5xSwrm7gor^8SoIk)8zOrW;%*lfr{JkhYfys& z18$RIHLH4;nbJkolsofQG}W3l-U%JM0n9l6c!P`(Qsa^;c)e(;4v6}wM`?meYt(a3 zIQvqoTNW)>H}@#zfSP5^_Y@i11NRN%R`=+sA=x`}s79wKCA%b%VbrEN+RQXYt` zxmGa|pzB;xn`f>ZRvc>I{I;P=SK$ik*AuSWyxO}Z2izFEBV6~x2imJt}f{q*Zp_24hGMZil=yJyJn)CoimHi({nE2*B8pGKP(K18~TP`65?G_MXqOH-doTq zogQGfsL`3Q6wGRDm92#oo;X%_L(hiRxy@MU7__!@*1L}gtSkMLoYRZ@_~vzKv$vG^ zC_xNiv&lWGPfxXFd8~aMm$IO}SeijQKT;dX|e_&B=dHm%- zs8zWvA>*M>{nMMMQP+dR`rE~_rSpJcBs7w5AlWbM*W0L@Py zCBCr|Q7OVRhbt6n$J|q!e7-H76ZgKpD6Thd4JlQScEGYUQ~FphN8==lThH7-!cJRV zl|J(5NBMG$T&dWf2Uq&wArfj+`~&Dvq0!8AqueaO+hs7}p;m!7VUKNP=-5Mbjyr^R zr&-D@Py4xP(6-%M^OD>msf@99FiFpD=@3mXIctMg!>(D*18TS|{-ZJ@wsdpF6S#Tk zb9GSjSg%&Q6%w6Aj?OG4eMJ1I_9gjsO+*3s`bmPr$PY!I%^jY)>TZv7;IQGO#%YUs zB;to&9eT$4XA$B!95hdXAHzAN879P;u%Yd`hNIVW9$!8%e>PQpdPi(GrQ_EJZvZ%~ z-3jQ0u-=sA?U+5GhqxHb*SLSN4r>3AQ+ibLV^fQl0cV5m0$_5uGqdcA{&@xHHfX%f z=yVfpABFP5imDCi#&Ba$bo2Ho$3+BAZo_I`ttKB2z%P)c*ef!1puM6oVWtF~Wf=|h)aBtNjH#uD z@)-t{+rxkgGez1n4g0#jpP|MMt0tm(`n-{Xh`y0F?pdQtYcT?zVK^o(s-J*s0|=De zyx$0t$$rT~1ACJds!czj+yZh>Uv>}lfe9`D1Hzlct6Dztq$>Ka0>AX>yy{0Ev(Osq zJ=`F_DQbHFeIu=y;2#L{->$bXEtLrKkF)ByVGNON8(j2cs7avKp*>C*KKgJeNqi-@ z%_efz3!uE^t^~-wNI_S$y}vNHwHrMZ)Ex*z7_uUJ+fl6&9A(h=bxGv6>i_^G9p==e z>G8TVY<6S#;bR2gkB0fDVd6O&Qy&eVRWcdqnFItfkn_<~^U>4u(KGYWv-8n&^U?Er zQgdbuEoQ|g2|Re~Js3E!l{@y+KYvLkkKK2IojS$HUuBT;q;@qvNvf=j(0jn`zTyhl zk7ViW@!L-l9M3N`Nc1QWJ@kqWCSTY;XZ+<10yJ4hq!0a5xVsxm`+0LKS5-B>+?Amw z__KQGhbm3;z3SFsTHG2Kg>@dIKH{@70+pe=+^~uod@F$@OAuG{y)_8k(`}vx5%YCl zJHv}cR1?4UhlvA#ycE$f>xKC*1eT8TthO_YOxK;VGXPvsGll`!`7ac>CjuOHl*1+k zaLA1~I?4UZ2&%r`jR-O+AUcn!5HWygFn%4jBJ1ml-S-6qS0O)ATsa>>dstZ9=poaP z6hR>$LBjZg{#g|?c6dwgo;Ylu8LlUWe5#-J7n!Q+i!5-f34nGn>^H4DK%gw+W_Rqq zL3G@{FFGL5QuwjMcl2-a`T{AYAU-hhN+t1q?h>V$!e{9WfTKBg1;(nWT6vKJ* zaKOH3z<&QDgMN@d13(U;+W6<+A^}p((|F_ef1e#J$R#cUr18WA0>}Up&SO&f6%KFy zpE61GFiiBA=KOk;4iFF6E++)`>N4~m+*KXIScA zdDW>X1xNtE1b`hN>3NTY8Q?GU$90jn@uU8C3vcJO3hr>3$R5HNF3KQ1r$I zqfqdh{~uHIU-c5(=%sWLIe#5EJaqkY$obqu|A%dQpDbXXEMJ-r5OoHJhhV+`*@~uQV5elH(meDN{~tG>#Fv7( zQ^4C=os8A=jF*3RjVQ`5*)Gx}KBGo*P9;c(TJ_}DCJ{=5lK3J1Fv_>G(3mIs$Y>!4 zsk2&}IsVYOsFl{5S`(5SZO0>&=aTOqji%@?m^E~bGRtioITOo z$+NmZjQR>Ru`9U$mKu&%Ks!Ir|c>ZBT*YnaH299mS&Zr49L)qS5qbC<#!^*5O z9814HJ7O@Nc$9$1tdf{|W~NzI9%7Ap)~4(Wj;NrvEBz6h#u&|$o-9a zuBkh-QWKGi=^4XgmF1pN!>jj(@bUZ~FuO0sPs>67K36=|*fFp`;m#(L`};$Je{GI)y}&BI$OQ&+s6+Tu za~#|6<~S|DGETqO%OO~Wpih+XSJYTEZA~h!T`tF^4_4|Jc1#uqtd|pkE5_tdteQtc zX3ZWs=Ju3)Ka*vdYC?*NuH{{MNF3;pSLj?%F}~St9#14{wy5+S372X-io(A3?oO`_ z{X*Ola|#}jQz^4-_UnmD@z3gbN|mcpUnv?xNfyn)@B-HXQ=`zuJ{Wht8}&ghd!j8{ zXrXBqx`kSDCz``6C?Yi>maB@o78T+$nu0ZT$_&TnQWQ&sAQr0zUUhQYMaK4Jd%;Nu z&?bb$@ktJ`JFqjjexsvj6{^~nSixZRX8s##p7!_*mn6|4rv2I%aSSQ$f%L|O4s^w{ zws>oM<9BqGXK9spuuPlL_Yo@Z9#BPwP&TnWZ|;r7jY)_*skQppYGO%V9Ihw`s8XLk zhBjUgq}g|b9Z^~8A)JISzZWxW(hm73Ch9v!An+~6r2uBDSv=l|B4UbpL@Wpj>RWx{ zt$p`NlE)FSirq88$r^aW@>YvpT5rpSE(Oy^(ds7t3~LW;pFpYiWS_b9#M_AIU{V&Vf`{HI-~8lnM=jYepUAw0 zsJN=*PYMmjhd{zoSnYX%*-W9?>z`+VvKVaP)MirZqF}J=j8dyXfg|_QDcIg=@EzLk z?0bfaAWi_2i`Ms*doV43RA^Spr7_RlzYAGu z+EN5HN3@n_o-F?O*QfWpjD=tLF+EDqp}u*!SjD>1(=pN5*M6K_wU{C%ZWLQ3VT8R@ zH2%nFp3*Fu7=>iAP1pW^xF9RH1&$R)BbcL^LqJ8RMk`H0CUgzZw-}7ZVvb}X|0ng6E-BaqJf0gA} z=jCba(DN0){iXi(VF>^A1I@$k`H=HtcfvZtRR1EM#7Us>GXig!%hOGumG;fCkfUoW z-!u1D|C9Ztz1P*4d(Xpby15}uGOf1T(8^pDag~R|F;91dAG zKD^|l*50YD4yl!-T<1jB@{sKtY;RU!d2Zd#+^Zhn(^zl48zVcKBU02Hvx+)QLd(0= zI?sJAEbm-;EUn<<#fx}k`K~E+$nu@%HFDGCv@Ledra$Gm=?1_-Qn9;!9Y{gbM5-h1 z>Xy;F^-s(1J+B5l$4>7K7j;5qquePK$kj63$hO_?1D29UKBQQ%)_*=(;9i0GP(D#1 zQuvL_J$P@XcsFGY?gsY;#Ld68b43roAo|KCTH(GnEUv3$Wz7!V6@!3#$*Bi@p6Uqh z2LAEe^Bqp^8Idw{1(u%-G(I8s(kWry3O6n$HAU&pXZk6HuxI z<+@Sn&&&YI-Jn6JM|d~y)~*%Z(@KPe1U3^)D*wR$8CO|99^?q1EKuGSeHVxPpC;u@ z{??#`7BLun_iuNrJ2%@NJQ0isSp3~gMAl%@-+-Xn#VAgF17Z7jApR_fosw+h-{8#O zjP(Z?`xP?qYv4a)1@Oju?YL zcPyy>A?SZ$>|;lFq#(yBRGdj|{LAf;3gR>^S{wzk+tbDFy>c8#I=U5=Z1Y@4n~L%< zRw|e=<TKDy0!l;2#U^NTg4}9kR!*o z>)#VXH%5obeG=>cJFWzX-{>3cdpUvs()?WboeJ>9*}o5y9U#~KE=<29sr`)u0|8dcCv_5b5-zl|X_lu?4`sjcBt?>sC|6ov*{#V&xkDKmVin~Pn zgE#8mBKkK0YWZ!_|E3MiA0ln_hy1%|0Wcg0u^(|A{kuxUoiQpaj#m7CvHV>&wZC27 ze;10mKcINR`U7m8xBRQN{$D>?{K2vL8DH=;<>dw2XJ${tC zY>LwVQIUVNxyQ-(4Muj$Za)S5cS(LLz4yNo!?RfTzbey%oSDk#>06xfyYyD|{=+oC zgsKg>d%QoyXZ26FKvkf32{) zIzE7Vk<(vXqBzQ^>=6OD8U1wv8c-DRf8maV{;$-Z$&v31p?V?y!)4^@_lFdZ{8Ne} z{lSHNZ{_CiQe5z{>Lh?TcSGO9Ev13fr|oPFf7 zc;#PVj3QvW&$x;wQhVm5dp~muaTEUUKM|JSbGYjjJ$@hjezaMXOYAIwhtx{I)rD>- z`s8y2srwBV2{xMO)f!$$byZtdQrBC1b91%;-JMfD1-ED;rRsy zUIuuBLX7)eNP+G-3Vpgy-;~H{kGUGusvZs1IajIC2d}`|QsFau^M&9>7osHXhPqOt zj-4RX9(84YC+Gud?`4Vxt`E)yBn7FTQ@qq?87pmb<7-O^j!_EJd}W|^H`OK zI(l5Np&O(H<|xiM-{}+p9ijN8y{}Rz3ZG&0vVP;>KR}U611VOJb$~9F9+)Yb?lV^t zn4qBNd?#7}w2wlP29l>xX)lrZOB%F2JPuENBQ4ZGue$P@9khou@Hhn&XWXJH4V5#8 zbuR1Nhaw{yszi==%mtY{vGb`CmZkBi-*DLFQx2o2tK(5KQ+;9;GHoT4rHkr-Hr-z? z;|){2O>sE&Q*CrUa>9%Hacs7gf_7s{uM8h%)c36C&i%k8_GZUHiVlBdWVB%?qSPpe zPg_fRLo8?wE^oajMssTfwr(wK&h=RB<968<)1eOT8m{Y9YGk--E!@I&G8EJ0HEcc{l&2Y;Ao^Hz9bF*KGSE8iNpqqQ?%U{7K+h=wn0AG|r%*RD@ zgW9n9WZ(z@{wU4ga?X;OC;ty)Zyrc>{{4^7v`jTM)5MgtsgOyQL7OGokxD7b5)GlK zgk-&$nie9W;YOipA^TtyWvNLtu4O7~S&Ae}WWDK%?>W!s>t0vQ`}6z#Q@obva?ayC z&f}cttC^jqSM4;1!&Vy=uipLL++P%pIgjIC`8;MPSkKic-uC{0`8JNq#%(rvZ(oZ` ziZ7cpE&3u)+0t&_xmhyT#{d3ie0qZQY7SZJGuVoIzRvP3vkquYN>SN(ZT~kb4LBwn zXMLv)4)$LSa~aKk!4A?=xgP{(Teu&VmSBD3UuKI(af=DouS;MUV~$UxuH>Qg6q9e( z7QI+qHf1hr(R=@(zr+LHs+_R(mp&5Rosx*hzi2Mm$(eZmmCyZRFz4)t(^4*jqeV+Zw!z%n zidGv;nTwA3b7p|fp_tI=2QP_5j_cs8cAF-0Io;oDW`NJRVv~*QH-l;Jq?l}6SoD=P zY;xl1S3c;C$&7bqUZUO^2i~!kmz=^lvs#gY;)56gh17ktSXGYyeKvi3;BWGHzcj_r zVtgE4e|uUI&0nE6>p(n&*|j~?9d{=acl^pm*S|E=9GlC%2i!;Mf1<8f)!R|jVhUvlbm;JZN#=>v4du4X)=BRB8`QvI_*45DTDF=W2rm?&2cj|6G>h4*LyC*LC*tTaO z;BE5$lu(zSW!61V1%|R0-!J{q(*o!#;>ddMZg?c);@N8(<(Ir%qIPGr*vl>uz0ase(Ztub_18al=Z-R@OFyyoF`EgZDpw! z$98|z)l`xmt15l;=plIf+NPD)WKQJ%(|JMnjOav>Z9w)(u#pz2IZx6dAg-R0yn8#s z#OS?_x!Yl5&g$t;Y{zh|_XQtWUkZer7kne=jkD=$9nIv>AA?WK6jA#ae=fmVDhRf} zJ19M2>^$*ryB{Ow?@xUa_3J))N!BUJk4r8jSdY7)@#8W(;}f%q*{&|YsCzw;L|IwT z(x=BoEP2XnH&W!6(CfG0Iuf{)A0nTp{Pzaf8xV^k@ikAmVLd+168Bw5fV6f41Ed8A zB#6OjG~R-BFt*=uCVr2&gT0&uqeB|dltL=#x4vk%F~Qeg5fl784^1$UZgtLurBA)s)5`+I zfFZs)b`}Y5@Bv}0K}pJ3#uAh*I({RJ)u2roYm6qf8T=DjWb#Xa)Mht4C|m5EO4y=$ z9%YMPo7`qU=3c$7|1;RC%_)HkT@WY>TNsctGdyG2JH78rY|GF2)6lMd(Va&-@pKNYWEmst>~Bj0a*^v3OMMdp?5oR!z? z{aEnyBL^*5#J$k-N*&su604u2KN6RmyT=sP6!(1 zW;JI`>jik!NekA%zpb9$e&b+PjZ(nt9<|7e^0u z!Ma!7abI5v^K32L`0_cRiL6lD4sXR4OB%e0wU)7#1P1r72@I;4%3lVq4Bl7`gpesA znJIDf#tfda+TGhLtu}AvWq$W5z)_0(TRHPRKcB8%cNOPaAL&(Sgu4&{$JwV#W@>{$-eyu~F~+33 zJ^~U5UVHWSIUpt=2`GUvBFkbqxKXW1_&o?9tC*D`1b3 zh?2FoH`AU}x6Jv6FwN>#x7n~L$@EMwU1CWnVz$crIcjaZpmrnF)_?Q{t97g0KB=6b zez<#k)H@{fG++{(N7#rLQ>UTiBbR*vm5 zX7-nFMg7tHXRqa}R_RtxG&4n45M#=m;1U1I{K23B$H6<2>9`gkATXp-M}bbqgb4MB z(b_-s%eosYNR$h zBBCVdiVfq`I*2^_oXJrCZ-_vn*&i<}O6ttT`~>fhL~Mk~F@xpi-iN6{ucN6~-n~8e zZbGKSfg7wt>vFB;Af9DT)H8TT*Agv@Kj0poq&JC6LIETW!G)}TEMJGXg0Xrxs8I64 zznwG$bYD7X5rYfR904{!!qTn;FhXiYM3O0RNE)(2*Z&91BOqG4NW4Hsm`J^=6}@Ke{u=B>C_ zQXirH2WpV!(9uFX{?aE22ntCNZ(x{$au^96gm%r75vso<1oXXO_o=147vg0%kZ<5{9sHnaM*?^LbKUS~Q%R3S%P1H9MIpOt_-Ksl(m@E$h7P4~=dYX>< z(B98vqjn_-%V-pq57TK=5k(+hw=HILn}MIoMl}zS_b{3VbQk8GGtpnW--3=Nc>Yn2 zoNA)xFXtS!o+_JxUJH1udq2lNAadLlrKe9W{!3EW`p;5W)@{gZaB(g+2M{1c15(wp z5L57*yEj0S0T_B9#<%Aj=e2Zte>9F$U-s_Cp{%UNu*bt!CCf8+>n^jOH2`^|^_u@G zLsvUf|FOzJW7lfO8NYb^^(&vGW&H>Kd2pS#v$igIiwsNhNMe`ir`}nmQ@@Y3Ise;r zSBV^Lu*RO>(_>n)u~TF`KPrB<>_%r}uo1MWK) z&zjhOHu49rQn2|fo~oM1+HIjmPhUktkfEKEFSg}_AEJPkA1~Pr;Sg22Bx%>< z^8q`ZwRGwxY9CGPTD8Z?zjf;&jl~it?2DgIJm|C2*+QoS>aFnfl=bH|qAtL_0}@}C zC!BvBxcSIV>kO?^lI1lLp^t;W>WBOtbrw142(tYu)k>Kg4<*C4ek^FK4A%(mot2q; zMzZ|Fyg;zW^3aLFT>IKr!JCy7bWK9FlK+u@1+&%bC`^Kd)aIl-lnJx+3I-Dy&sJJk zUb7-tAMAH5J>}uzFv~0S`ag?bEnQU!Yx!MceHq+Y5@wl&cQPHMl21Lm5zgD#y=mS; zg*f$erF{9Y)wA5MUoZUu_OAyrT(VS0Z|6TfwN@jh*v+}o9{#tusxrVxQDF7x3<}8&u ze@XTkjF(6i;}OrntJye z7yYC)tT!1fo3l~Mk3K98v~Ic>FPHpcgSGp2vK;$u#^%2jcF)=wdf4LmtEA0J9fela z(j5CqhN19s);A$d%Wlafud!~ib2(e7{K!o{(3&lmEWL~W7JcYu{JXg&@ZndMMrgTl z=&8`2Vh>jpF)_0}N1Pwjjt`XGJvg^{asF9%xqJJJy(B*t;e4ZpuO86szmbu3eAc$V zE$Xzawrt~e-?cRodZt!0>*4h>e1}LNwRvC zyh~TfkFC#QoCA(LJv7GH{N7aS77q#0ouNrwB`^&6F~a%m#dD&MyPvau8hM9#z5U9L zD@!9*hgNp1Grn6_FfQa~8*B46`lEHTnPmR>@J()|p4m$u+`F8a@v`;g<;fiiZaW{O za#dSaCD(IAKK2wn?bEc07G6_(ucl0&%!Q_Op4;V3C;#<4YkRT~9$IuuZcnGx$->m0 z7wI!|_scF=SLz8XRM{as^RW`^t%ciOn%}L9k(#lp6P8o7Ia?9m^RR$x5B0*gs%@VS z`}qCFbz9t}ieSkx4~@gO#?hOnZrFx))D#r$#6MwI>fDz1VbK@Q{P-9aB5m9f23PP^ z_!G=j!csGfX}R&!c-V!$?miD2xKgwkucIxTqrRVNcdqN|JWlH;Yh9-=?`qqfbG!j} zt48BOt%4=ZHcd%1l)r%on6Dtwbp88u-nwE#7SdbYm5>9UX`v9_U=nj2sq z3DL6bqs?F^Ph3$}{Nnt39&-0IQj)lfy9}HA{>?_ew}NR3#QdoD-1>nv1DqWFp*OO=w%l<4h!U!I~@76I~)x|a^(4LzXKJP)(6_xRb`MGE% zjoU3ZqQL9Tn$o)OKHrYooI5QyqUqfeU)~*ryNUE}=9hPCGFoo@MelZfc{eL_asFjb zxp=OW^x}>$=Dl)vaege^P1CT5*W@ZacqDHjAgg{&rJUNGYv0zj-JDcc3m6?`CQGxd z*i)4P+mFx$grrhr-Ojb0kr-?c7Q*}h7=3kl)9yMIf(2Pl+o*`^yu8(Duw z0XToJy079=iz_%OKxwn4lIE+OYsW=((v^|Bg^1*W-pthQN@)+i+45hvy#M#D zJD=aG4D+xEuZ#KCsFPEslx3#aEK>O4gTKof*2hcE5@KaBfvPwoEFo@hh;{4CF~;F_ zwhEI11C#ELcTwEZ*$CUjHLXf{S7#g0B@JfKPZ%iS6z>duqpk=GrhmTc%zATijCcn7 zO-*x`NxAWf$+CLSco|nymu3e}mgOAlR4H%^g}mnpyIipY<|r5+0(&!qQ1pnJIl)=s zmypgV%|&jbQ{M59c^|@@CuMrsd`ZCYnyd5j8$~O`%CeW{!U}8kdOTr6_)F}`^6brX zAGSqlo=KowHbOirlWdoz6)LAsJ0Vx>YM%DMxz#+pGs|on_vsZ_FOpIr7$SkI^m=Qp zo^uE8|4mH+*rGTyaB?fFT9X@QRr7B_>fPX(gE1X9!4!DU)m-70=YgD_I5P9Pm%huI zHParPmL($;lA%+VHftLGS*YwX1%c+QytNGovMlK&^#qTuNA*V}0-5oDvrjF|GG zZu9(%n6w2UIhikO#|sMs>oB!5l~KFbEjJVnmn5#ES-G7}h_#(0__Ur^Bt5B*<$VBB z9Kj_aJWrrWU&r0H>$a8?(jH0Hjkk?d;yuONSX*oC_b6`J)=4P4j?gzQZpSzAa#q=a z3)1*JkEsuh>}XbnleX6sTrdl1N|$~zDjSTvm@7LfhJeurkbncL#O;a!_z{eeh`fF0mT3Bd!mwe`{o%?;)`+ z>2t%>)ZiMy$2Ux^TXK4o`4R+{f2Eecd`CgXrg~!z`%bvAHJD`6{tlk*%}K2+BsF20 zo>Q-y-^G%PSI_E6Z{72kAP1stKK8a;;pJuIrY-1$EJhfQ8^H#71b;S;+ocjOv%G>g zGc{RQpgTGN@gVg&#BBro2mnBnyVcn z_7F+u7*V?!8TQ#LwNj;|9;@mmv~+>XF%HB@XV#p`>f$n9YL92s%u4s=`>a=i@NhJ< z1y(U0$uh(H)E!oj3o%|-CZ%1Dm8*3%4`^$*X*Hk00Z23h zx1ks5Sp03KJ_C@Iq%By2XrZt1A88+i1nLs`ow2cCh5Tu)Q?k4)t*8tB110gY5giFB4JepDB?B6!H+sJi1{}O*bf#akJy4CJAc~~Z+^PFLqNj3-ub;Wf?$1l&V ztOoK-J`})rlZORTAaN@r89+gy)xfe%pvWV3`L}7zP9<)IC~)mycHgWyH>qb%qncLe z9>P&mY%s2!_^2=!j7p)GdbHkMmDn_ygW}5mWvaJF`eHma4LsTh27(pAjN z?1;;Q;_boL=HXrpd1j>}W9lIfAdpIkhfvH5=F>xEk7H8r zjbeh5r?C1Wp(GR-J8n*{+jGS6LHYO< zVl{i--vT!Mqn=kYY!+;z8vOuyuM(EfE0A@`taDWG zQrXw721?f%-n9<%@Pp%YG zJrLla{zT$kndFR#zt=PBGQ=4uY$|@KRILQgh^T8`FO@r#bJVPvJb{{)<}2jTXS^#* zU*lq4zk8EWA_Pbb#VrVhJce}+0Y@JXK-uM_yot2Lc*6wlLVDvtn-LK$l6= zWkX~0%VMMGWQq|N1l-D`o?@Cv=*Y+_Sc4G7WT;Ezz~N{{7aWehdlDNTZC=h0###4X zj>3yO%;#;PCK!-WBG8vp-i^|APUXO8r-KW`6H=#qs-V|pD$t>QdFHktQZYvosEsTU z+cSiVNbAR}*_0>%ZpwcsfO^P}1*wE6!5~hnQW(py@+d&MYz1GN0#%Ooj+ z?0Y1$f;@|?B3YUVlnkNvU$=Rr&+AIeGQ@WnJ%ipQ9Wn)BH*`DR8=g}A_1}X&y3tps zU0v8#y6lwx*!?$8-#C4gm$JA2tB85}0o$@#~bOSv@^E(tzqnplDH!ORo^LQw_C%5$qj0Hn-xRI z3Or)looD!Q%=@QR4#*~@o2I*f`+~(f#kNbrTAOp|?3s#U)y=1M z4j1j_FV|l$8#bFJ+vwDlQ|fo7=7?%cU?7@U#ft93CLy&HYz%9dto$up>)U}@GGR>=q8MZN4cIk0uX%{rGeGvUq2Nj>QW zE}CbyD4-=P-owIA%oIXW!rgK01Ai382@v74zG z?PxqckYE^RYX)F-JaC8wfEb6S2xCQ!uN-*Zs3!3+nrDEw?cF-`-Vub?u>Dw3^C}0v zP3=CLSHo;i#R%idz}V)XBJZm^bJ!jvOriyU`*B$w)G5#C9%Y|#++j#-;dwA1ztiV~ z-+h+3&B0EL4=_D!CY1>mVPwQ-{|Eu0?;9nOD|K9NaB)K>8v+c&ZYl&G;#Rg1mM<`4 z_7zE8vl7eEC|6wH%~N04pe7Mz7}bsrF4|3ZT2%Fj0-A(y@(<4#3-k&PsbWOU;}L6X z{zIcdCr>V;q!P?Cb9p8K_(LEX@bV}!L4pBimgP*vm=@rIiL`>7sc^k42V;hS_9C74 z6;g>0Y)cTcE{kDK(cpZT!W_9huEK9X>3`+xFbQc=RKj$b4UZ)yOr z8F@K@c^T{bgD#l^hxk0QUcx(gU%o)PjvI7I+|Slr*`F!+Of7j8%qqBLubkpCOh*o6(LZ173l ze8_@R^T3D`z~6`?2h2waL*~eel+gh)Zm^S^>_k@7cl(+!zya1YS4#J@&h0k4-OpMM z%x06{z>@IgJ3qcuU@@S3bn!f5e83#w`;OScfloWF(>A533KN{W zD0q5QT;3r;R7}#$G7Xg(=u%vOTZeUETbA!<)X!x0EtqkA!Hs0e2;+WS3%e1P?3EbN%=WQAPiMtz5-%@cUtH8Fi3&s#*!M*T=hU;D|`Rr^X1x@JsPK$5ytM(Qy%_<^5PxHzpoH{ zaC5T)p_A_dfenFUNp!|@rT3OjH59^sGcUEkr7jP18D%AsITe5bNL&DHl=#Af&QE-E zAitF*@$)6j6@&u`4wxvXvzD@`3Bq`wa)6n#P9zL2P~tL@poUGT3G`x}`O?>1Z{0k! zXtc{tn|wYH`Vm@mNPfz&Nuc<#X+Pj*Y~_iv16O{@oW8^GX8*MXD`-d+NZcZE_$H@} zV$oRrt@sHbBXd}f>N12yFidBMJYj&AD5pCB)CGL>_sutacn|C%(NCDOe3?SIZw93q#GuYWMFG0m5znv-ohPfIcd&rY@95(ZO{H3Ve=xZ;aQX81A8 zT%jLzF67N#16oCxweGPc5$NX{0l^A)Mv4zwGzBql{LkxUObj#HJhO@mD(}n8-)s@g z_sD-DU*L7%0#v4>Ac2yX;10RTHYJ19qG)yy3`i9t8L7Vh;n>5fMDL^XD8>>r{o0=a zxy)Yz;+1(G^d-;*-&EyuCf7JsRH;7MmoL!Yy-sxR8DK4f1^#554Jc%3vLKbRY+63C zqh8a2^Vx*4m93;l#XTszJl_O2Z0Un`aSNzZA@We?x>>xI2U z8%n2w3e+$RHNTx&N>~q+g;X@Vim1SLETltNhwsaKQ^7Ek>c}8eKLL6FUP^J^>WgA9 zEXA2BLkL)ver~%nq*Ewyd#6zW2a4rQ&XpyNBGss;_!=*z6V^o~pg21Xv`8fDu;O~B zhgh$P(6xu9pa67A^5@j2mA>1s?r=sPPqV-_g!?T>ph$P!&5GoGP+a^}{I%!tTirs1 za6KEV8mNv2h#JUKQKa<{5Q>5+Pm`J!#c?+3;a-tW-jBrT7ecfS6P19gB=?G{>Z9Ci z&3#_y`$s&}hT@6x7gYQ6P{dz(^prLzzEM)>f}sSAN21ybXpKp?j=rE4Y0mc|g!@d< zH+AaFB0i-6?E*z&!gsVlPRBgT2)mz$(h&upKhrZI8W-J9l-okghmp@{e=r8tqEw#1T$rM5f!+8;D!H+UP5X8Q;2>-f zK^i1gD%LI?jGZ$Tn^D(b@lIgAgQUy~tXu_c0LIItokXg7lGpCJ)i-ZgcU%j>k0Vx) z(#Mby4^)Zt!6;J@04FZ-px5BjW+q#ji#?D3r2>Vv2oXDvP16u{KW*F5rdsAsz&SQK ziM61k1!EJ`3ZQZKBVG{{7TKKwchKG$)QM>y-_jh-h-9)2ENrvO?vJHoV^XilSZ zQ-RYLb{3I@>^4dO@#_%U0Hu`7v_?tF*Rvjw7WaMisaF1Fs59Y2;t(pPNxuDZl*Qh$|Bc@6Q)BMq-6TiR#jz8SIU_6 zI|&A(s15z@{5CU&9IcrD|2jR5uY93H1nn=#&E+xJvta0z>B%P4VHcgs(vsZ}HUmOl z=WqVQg_$%^q4`Jni%A@++QAuAOof>f(RvfwU!-A92=-Q&Yt=p;9$m3+5V z5xZho#4sIRQZZ#T1J^bc-p+R+e`r|4TR;(gA|+)99YrjEs2ML3_I z2}f$n=Ui1kS`^bL6%-3-U&UG7JOD53-L8KPo`fLiFjES$u#p6@7L-UrQw8KaVZrkF800ON$ItP)as-WOFF`%{%ia})#+Qw)A~8JW?XD1W`C7WV5pEFTM7B}cXW zLDF=p)sy};qnnsc-MtbniMT!yo+ebgD4L`}=jD^WX9%l+u zG_!7No9l&;XQcB~J^>ne&K9IG=v}JGEYInC#TI<87xkZquWN)KG+$s96Ar6pgYtt+ z4HJ^}!)&>o&Yjr0O8-39aiQ~(mpL1?M=j}FDq(cXt`~k50RCGL5Z3k$+*PT(U2W}eK3bF1G|dieVWbTNX`{Xlh5?O z12X(Qp_4u6=|bC;biOD!m;wsq7Bp6g3{n?}J-Q9-lws3rKmqxSR0`NIg(Hvl9^AU# z!lWKzN=o)BRy21&*jerVB>gq1izbXDZfG;Mk!=Wdw^uXbh#G~41ZV&nF%Vv(ud7Zi zZM}{;Rj-LCy3W-ry>&iodXw|P@0DnKj6|sr)t*21>K$L-md)IX6?G z5tsvNaYS{In%+F}j2M>Tqw7c1+L)S&c}Z#(W;<*HBX}EtSVjn{WR%AREkJ=`ZQ$Rd zJVk86VIN^N=s!fdgCKK#8LA~kDVVgH;3PzhhaDE`Z!VML+})VdOcI07s78l_N!kE1 zr)&pt%UB9IlN=^1)M3@$iS+1SPT5)I*gWh7qwJ(uXJqF{XDF)^ifY8}XmzvW0hSka zcWD^#i)JcdsdxRy19LFjpntOqpK?eG7MB2xKc)fL{3=Y~uAvJfhuFA6@=DIsBOFIe zob^wqn%;`Q=>{$3wRvH$RYtEP@jUgCV-3^ zeZYwvz-$|fRH#Boa!2YJu{nmOwJ~d>rQ2{l8yW^R3*g{&zmtWaDMp%A)Fw=c0ACR> zcj15S(c=M4er!AP%W7&Ip}9uU9BFTpP~_)V%-cxvgars~jhGk8qHH`UD=(+{n?xh6 z8uS!B@dv{GOphweyablUM=)R48nfOo#jC&}{6tQS1BHY53y$V}`55N#Sa2#r8&#h> z1Xl}_6G4K>qTDt>6;l7yJUBfkbto4POFjbAjy~E%MUU(*0i`m(wMI_PNg(b}Y}OHB zM4wPWgB2D{aC~r)vjZmWl5YQ~gKf(4oqBv6HLn-tBZjo7iRHPp#Z?4=1(_q(iJvb} zP%yC$ttuZ!D&T~Y1qePU0BM7eq$%ih^P3+~>IrC|b=GG+&7MvnrX%?gaf&`CUl|Bb zjVDn+Cqiku`M?!(+JX!t#|os|Pg0yv#z2uuB8UrJ=An#r{tyYPQO041GVP-rHfSkQ zs7j=@?El7ty2v`KzA#B$n*{pBL=jC9@ZE+Wf@t(Zad72qGkZOJGZX2NNjx9S`usq% z&7Vg?k>~~#YP5GZgPqu(Huesj!oWc+wAA37m7MKr7{VP^6jeOf10)BqpvQ7^+p=i=^%2p|x91)YVR+U}(O`X-Y49*-&^k5%w zv<$5~Jix^k9oTz?2aqJwTZFUZnWO{Q&1FTA0Net@ZMTN5B$0VVRiME@{qf*qnYW-) zR5jxVc?MK(LXQ%b?Co7o=leqB4Ws&D;s8|bXXl*iNLVKXG!C^W9!DUa5hc*K!Hyqo z{zB`spLL8}#jdA0TB3#={XVj`ueU|r!07c}>>H3He43_~5fLExP@{wDc#ybSw1W!D z7urK8slnY@c)SOq7nh7B9_b;Fnx0V)RaooO6`%%1T^P9wPeeXve*OU~(fJIG!%-ZZ z`jhixvS8c<|Dd*ke%ZjVIDGFy(+zVHPDXjoC12ABb%e;%S03tNd9xZL9P~#ghyXYv z4;F5Jz$X+HRisNz_WcGROperrTKaRL)lbW23@7rkSvztCXlq9P2x>s`IaA#iiN3N5 z&&~f*Na`8c1&UZf>z=ucZNe6T+=UXQ9g;jz;Iyd1kAE;7GpP&8>rn>)LZjeuK4cBz zXuJNee^a%W!!IM2G3`zJb`rpZzG#{e5_T2T%Z*N3mxiTKt&M*}J;{ZxZkH!HkCE_x zsc}cZ;H2|d_uu+1G6n38OM})OUwn`=Yj0>WM5WO0b)H_M)eJd^h7Jo&v{dBL_7-U% zg2*HKiyDBE5=P2QHvKS((lqk{409-I6csdc78mG1^BA5CgMS4LIl_EdD2O2olMXV~ zc;xU$i1JmenqM(B5TyZoa^j24Kq>*VDI#b*3w_Ab(+a{5GbemohhKz|ubAj}9Ctd$ zrgj&gT__7Px;mpBF-F%!)elb2h&vn!&)^77NcO_c2l+&i4n&VNnGP8q;Hcy1L^L#M zXkcS@hFLR}67_nFHE2DDO;8#KBeDo994tYgk(>AaxQQ5vWr4iH_B!8F@hUU^Y(6{c z6DG{_LM9|3BIw$qp#)lc1Uywsp%5GUjWa`7;8_G6Dg84+S_z#(Lfh0S1;r~hE)$mw zBl74+G7COacaEwe4|%yzZ4)!HR12;Q2E!>Wu!C!a7-N<#+?(_ZQTzf7V;xFH^2yuW zjHan@PUKN3E~rxI$BSP^dnX>;3i0ljLCW}qIZCHezm-r9<1F%xYj!&#pHc?Dlxcp2 z-)-U{J8CZY@DP3bhJ4aVI=|HCf;i(Be()G^2LCG`(8+lAjR9Nr7iIHT156*2d>D%T z>whoc*;ltuduVBRl2*C50Pr3BMvB0WAvqrqpHj;emN>|na0E!O0#Kp_$mHb^x6 zdg8lMmg-b}C?LlwajELS=lNfx0#M+zbBA?U3ON)9DMY_iB>>{9X?LiE>FFal6+ZAJ ziVOSLop6>Ukf5ZhrQcTK3rvpz>h@XWz=Cc&3FvCnxdirVC<27OQ^DGmd~zcU0`eVT z7Li8ql?s+T@CA|j)KdR)TneqxAYf@^;2|z4MX{0Li474NO0;^lNhoob7v@O(+5o{y za7C0MekO$5qp{CW1tt%uj!`Vw&nR5L&i=DZ#Lp#%-&ppxbt~Sf-?VR7i?21B zQ+T$-cVE>bn;*)b>{-7s?xDi+Q}ATWbGtj~GQUra=qdcuZ{KKMt+^hQOnp#I~ z=CP_r=F{+gE!?j-_Qaym=HA3D33F>&&cT1S6>w2HREYov^ zwb6CjEx6jSY+>B>dsW-w>{<>_6=w~Mb$fes*T4JYS`_T8#O&@)UUuw{=zF&5&kse* zKDgc!^QQM=$J)8aQgUIp)^Q?h?^YPyE8Z!6?uJ3>}I#l>p<0(VZT-MzQTa8>wG z&EI1;X&k5s=ukFXGxu0n+_;dW^mKeBiM;X{UwP!bBCfG6!d}~TS8VGB>%)V7I={9I zE{>L65N)j9u59S5T>ShO*N`}mt1Vt{uQ=xy7VVU-SPCABmVJd!BpDb6u8f$9vl`h% z%iemZuw#0eQdn5rjMptw4n)iT8>Q_U7I!t^=DFBSg9j?Uzt3IBT=vLZfDV zCA^WjOWJN3ZYnO#6mx^lQMeY-8XUlH8(X&pHupw(|h2uV#;_}jPmdO6?bC2m$i^t*GLNY4SZ<`xA>+8;h6-7&&xMhDR zGhyEHkKJc8p6pan-FDgYRYC>FE_%|5NZ8||s{tI7ReyLW1hl$6pOul4~5B*?%s=ma!I9JPfXv30d zHF(J3(>$}45{2GvlNF1eI~vDZ1kU>oez%x<%j(3-xa9dh6{$hhd9Fn1-XOCKHh@`d0E3BErz-zhPd#YH|K0g+dzg~QaA3#Xh8I2 ze*~w&bK>5IHidp`P0K$X`t+%OZ>Jf|d*7_U+A+l9XLFU;ox?^R&8ZoMzvZYY_{9&7 zDGXoWJ-nh=o&CsgUPhUtvDeUXBYV-|A%{R$xSzE;E;AwX*ye$9S?`9(9A{&*rZtP_ z*3Z~AFg|AKx}luHTxVmijwvVKX24TkrtuL)qQCaPC7l_wqie?u67P4~2#H4Q9^peC} z+X3>}PvlL~1$GwS(^+wJi6Vx5dDzYL2VO%npU@##&eTix!gsF3ffRq$TDMf4Aw5Uu#W)Nu9XV^W*+;T4_R zbWFdip<0hv?zb9Wm{<>pcC|+=Qs(%%hfdxbYVkuMF3N9vKP-x~*skD?iwVzO^m2UG zRm*`LMcsd&D}U}`Tx@9m2|oW`H>j2uhKNix(+2q=0xt@-05ZNtSRB_XKbz! z&6vZ>VCctP?qC0IcPKjntW&ogf1MV{{JNU_y5JoCY7;89{U`5p#^#&SlRP;|@66I& z?tdEQM?5w(Kgc#QyXUP(E$%1kxCeBDC>39;Bx0Ag!mw`#YDdXM#ikX`RG5+2D$-7&aaADTMY zb)ZjTpFx4OywO0`#_}w=#O{KaV)o6smknTI)25yoUthjuXr2zJNa%L=Dj&T4SD{4I za&J?UCcQ(SZhORg9osi>2nYRedq!u~z86;q4z;wjbsgxrbCWe-0AQ?>jVj_e=*4FN zZqj!*rHxyWD^Dh=4>f`ZauP+Cys95=6ixeh`|+l2hjJ39Kbt?C&OH=#;E31rUifR9 zUE3Yl^FQ-4F07jG0z_gslsC~W#uqM4%vcRHP?eIIwKXSKB0SLXnf|iU?)YP76?P$7 z5t-i2FB)V^Rk&i2nTmE1rp1PlwvCD!vlZzeN6&nUaPW)QDE-sI?M(Kqz87{$-5U1C zUl}{c!fq40V^m%NlFXXi7Y+Wc@@j*8eML)q|7`Z7v=?Hx{^{1Zx2Ft2A8DJQs9`0) zY>P%Eq!k!pGV7J`{#b&Awi*X3Re<;8QaL?Xr%98Aj3e-aTxr@Pu}FCZyF->LoSrG~ zPuu=_Gr+;VYf)2^OKN)0Atg>vLPyt%)+K%$fFIzI&F@az+L1?k`?}TG&j)T_YI5)I z`!Kub4i4IEfTR;?YoVyYGbwwT_pG{dg^_zCD|>aZY-w8Z1_Bv){_nJ81C={Q@LWt< zGHk6Tcy{&7-8xHOt9>N3x1HEsz-`-XOyIa}xF=*gg zOtL{i4f6!CP=DVT7qbdkPE<(_@Bb9wPNeUk{ok$nQcG>r8)DWmcOflsbh&18a3=^OpIiO zsO2OYhI&V=e#guVy8<&+>`oLm0(xd%v#~aicn=Q1F0V;>;9lWqT*HRx4IuA^H91DQ zn1x2dLUtSvX}SznSy>8cj|Yo4LB!Rt3-ASxf{kSDUSOEhwiY)Rfrae1bMBPG9$`c; zQw^T%vigeF_O_c~;+ZSnE$wNKw>QO zr~_P1EDe00NKAIcwm5JqL5#2sXQU0n2Ag1fN11K_?SW^9=c5tS?u4T?2U(|XX}Z<2q)Zu|I`u*_Fc%C@2ZwkfELMj zV3@?N@;3Ee4uRZgw?FjbW52GRNpZG@ZPtCq4i3GphF4PGoYGRTPf;AU1>`Fo9=iRe z&&v$b!nF#Ac-Q_u{h{HvHv>3NbG)|0OZ`p5^rgS7b^!He8xH9YN>pk&eQnZoGaxhl zakqEmkXHNf(4E=<$DW#}&vumqf)cvv0^V?#3;PDNEM&HUfL6`NG&lRRhh&o>PWzO34H{VvrmclKMZvu~={+2lR3?B&4)yt|UTO>s4|JO)WjBWx;V${oh+0-eNe^rdh4^p?eJ(A4cozhFvvgC*P1Y z+1-?Dm~2{YcD%sTLN~+|e|286&%$+|g|7Ua3#?`3wco~Tq$aDUCf}&24zH>t=7~3b zob%L#S=9-r#Ji{4m&7{Rw>E)kCqx<^{zWmcJwCB7ZTi-OU=Lh2fAgJ7Vn1%llhsN| z_I#Y9aPRnQN6X*Z1~@9N7F;L(MEMT@d=K!sD;MWdp%7u-J3x z`HZrc`cvpsA*MSNCF-af1H6(yF`EJR?94o!J$SUUZ64fh_o)3lQ=^V&r(P8_wYqXH zSbK@d6;qFp}QF)a1X64sXduuaosOK;TLpFxVx`X=Jb2o09vvtoFSZcf z28-LYu)q6RcCx2iXIp9;I2^qC;=#q4{oVY5wq_4bWzX_5WN=p#9+`87zUp!teHIM= z{VnxvN#)-FK;^WiR$ z7}?=#XVPTHdZ(MN6@!P~kB%p#lgHXSoyE`*J? zrl-Ym+{%W@p8Ti#_7nKMd->#BKBmeY8XzXPd^ec!BS_vCaQysS1sH*b`AUdEi0icJ z6!AtPU3D0mM-&*?Y-(7_W*k3VKB2olPJ2l^IFf<52Z{c=T?YJnRs@Cg@x4ZtAKdxDCmJN?_`VS%Ze>=`w^6A0gk~6ys?T zh$M6Tw$7)Rq{$2V%gZ{`Tn2x*ty>%{6x_}g_pcuRZRG~|hU5Kx_9 z;C7D8K8vXU*u>8DyW{t7F$7MTn!)Mo{P+Hd=S7a5+dla16xxqdTIML&) zQrSCy0-f90uQI7N8(Iy>ijWVly3~3Y9zKC2=eKA%uncoAbAy8c1i!WD<*`V-u+Hdk zQy6AGgk9!oz|6%VbuZLt?Ipb=PiSlyhA$UCgf@sCan3s42atpC_+!&=|v@Kn$%yLnl z2Z5b#UMA4??cb!87_97Hz_W|HN75D=H@yU?fsAS*5;Wo>It+3rGb4eF`iE+gDkp&R zWPM3Y(;rFn6L88*Lq@_ltl~&Ac3wGRav#AaOhisN(e#T{j>8nnUqKQf!)Q;gPx*S` zZNcL1ntte=PICk%+*R+9f)Wo{iq_FNN@nzTk8Ig|p3m+L_Ti`z&^hO3ln6X>{j@V# zpH?@dQO03;2NZyva0y0W1IkWxK?(dp?QKa6>Rzd;Bw%u#jFRE@Z!$_4YIGLJx8Q*L zATofOWQP+W7sn(63f({vU@}JJN2W(wz64cL2y5?IclDd8yBLhqB#MZQOB?aD$q8^o zh0$M&7(8Ka#%Vt-f@FwW3t7LJf@q<-0Ov4P+*WY<-I$8u+?bP*0MVq#B-8j>N{Z zU4XH(ol=n)4mI)y!__*Un&R$-tYWQ#bOtnK=#F8<8W8Pp`gCv3RUqbba0uTw1)2P! zj$tG)`5$a8DwE%~EJ2FcMXW@ZBr@chFbYJLd>FOqo# zZ?QNb<_*FFF9U6PGrbAIi>#6_us=wAUqVMt0Fm(g`~D`;)MOdJUWF^DDIi{BMQ_kF zaW&U`&m*}86#EiV_P`w-R1m&b!HELA$liI|LTGsOla|hOl+3KyH{hp8Odz&v3yObq)7HSYJ?{g{b9wI4hK-!@RdX~(ho*^Y1+QD^(Ckw||R*ssaYoM*pk|l#CFDgMIHJI*dj$#{lE8W>V>VF_RjV&YhZTZ60?vU*LB+VfB`@lOmACpd*2T}i# zA895dLBnJ+R&u%&Y*=_{t_Od$O>{j-8JSLkejkky6lFx=qwLnt;zB}oBywa-DexVk zz#nAZQoas;@@-VkLY@ciw4|apfbp}5?s|rZ5SX%e-ft-cmcXs0 zr$NP*m?*Wiy^RLvX$OOa6a~o26vtciEr6B5wV;8#KB}c8H-lc($bk3X^+%XsS;R!| zM_?)^?6%PHJ6gIX@;23*C4 z$@MoN#SrvqnSRL8_#x#St##4j=VpvJ4$Mo!NUIwQb@DP=6xjSd%3XY%QCkRHHPAlc zcDi6u^lKAMmIz#XZK%9VOfj!9kRO%4(c0HYElmwS@**JK_dA*aF}ld@;Fa0BU~9CE ztnY_j7d{%{XUyHN)O~kBJgU3;m3c2W@XiEPsc)eOaT{W~vk8(|YUUs*vg6YX&Q<)( z&Lvt)%7Dn`w@t~EG{ZU1fD(2NKmm_*O!?P*2yBazT~;58zxz4nRAQHne)bJv^6WvV zJwQbBxtdHf!qv>Hv>CG3f|=FQ;y_{zX>OP!k6LJ=IqQA3%vz&{2RxwdBB1NuzO^VE zH)Ll4dFSWgI&a2pp$TQYP41)8K3n1yRflS?({G4iJ8r4oE<{{q;8@7$0tZtnUoP2H6pL z^(1o;Ye8{CGKVe6fQT2;)!9cPgCq{d>6kb8lJhYE*8g~LB#Y4?p-OIox+$a|Y8qPd zW2)&J1FftO5O3sB{e?)+hb9CXVef{RL;Ef$xlC(3QEF9+KzF%EoYaxr2QO(kyER z-9fCee~Z93wv(GN;}W~^3mX}wE2uxjFy#^gf{K8@TE_mXd_OFbM$=&tqfE+ zc~5|}dC+7K>Vy#RcR$Np#P_YEr3!<|Q0V@Ua3|Rn9|ZjkwIDG{-~Ly;?$vpuH#y3q zOpS%RWk?!-WJ)2%NW$uy0T*`b=#NArF+o9>nsd<&gj=ff9i30Ml6~xW5TLz%8wsNs z)`3cEYjO}`t?a>1Tat#NhCk*G_tbl{$L?$bsdLtR=zC=2(iz1~KvJ{f2Io@3&DH>! z{m_iwWw`o%&ZxWVLUI{R&SJVNWGLog$O(9Ngskf*&-eCXXXXSeo4$LCjg1z!f;aSC z8ee-YG@&el?w}n@24n!K6|zxO9^S6#KTOu!NL|y;57fJB10+k8~dz~T!?XosJh%G&c zcO$k@Swj;EU{e(bEn0R6C=&bmpE8P{r={%*85=5gAip(4AA>SmSJEdCsdtR*vVT~; z>3nf~&%7Dhr4*Zz2V5naXnY77QELqvaeX;|y~eVubzO@+O!v>yendh}ZiA^Q)0K1n z+j_GWmfif_w|=^Vt!M>BD9hp0_umh<1oGVvAN45o`&A4Y>7v?0&-%&8Woz$&eIY}> zzJ?4$8wNA#nm>n?$JE@t&UPRFNdy`+P^Yl{UoYm5Ilr@U@YOqLOMiQ^d^Dmbm7vqr z3!<7m9Ri}AyP!iIWf9cncZ-`vk$#LN0y;*t?&H32nsc@`=9iJfq9%l(YYZs8eaGj2 zs{fb9pH!$SYq<0x@AW1>5b$Zh`%|Y z8x@j2sYNJE@~YL2oyAFF1}WGbt_=|I*#T>_y4c=+I(2r(D__x2B^tp{O^bc9F(9UK z*`2vNifNwCJkCefz)oN2Nc8E^)ClLfB=eSQ>I}kzh=w(uQNDQ4tM>hGZ!Y@VORX!` zX1FE|1WBIpM9;-7LBxT+(5Z#dd$F+66bF{IGFWq0c8fCPM@Nq!37+4s%^Bl9-FkXs z#V0WRzrT<+;LgS)r)^4vbJ1T{)N8}WcT4MAYWn;{NR?1-R2*Vx?N5bZjS9ag!dMyH zX#-nEYpw^3bOYfXUSu}y&Q?5oeCSO+ZLOem7nEOjrBWkYxoi}a28@Gc$|m&M!LvN1U?TA6ilxH0+8P(-FH{%n*e;YwM7{OeajK}R^)?dPD+O+!sNxw zk$9C3jRLOdqvDGrN=3|JNNFgHF#w^=$%6(v0&KLx&DRG~*NMc(hH`i?XsLk>2_0l2 zhlU<2`Wow|6U1HP^IYqQ0qc7#P^EGYfZ5al&lXz?*ld}gm-|l53Ud@- z%r}}%Lal1g#M}vZZ+BgktT8J<*@IB2n-Kqu%lRf@DPqYqCK3M?lC!K@f?Fzafld8W zLX4vAy19nzDJT!U84ff%Z}BBVdyR?gj~O5uO3XoKg0@*TgvdR!Lpx+qAKKd=6=_$) zJCW;1iAnuaLcJnhPpmCbjoI^xcblv!^~Q1b*6?H^3?;u$b0#VfB|qDgaOx{?AFvRm z?T&wasb19)xJFkT34Ns}Anap=vXwcLuBfd}WdV^u7f`z4+H-Q@IpJxXaq&f$XhacMiOw`Ke0?HQtL9?(ne7R9cpHxJ8fA-2RwU3%| zV7g1&UB*)X;B88%m{<{~+CaH#oRE*QoPrP%r-*7Mt)TYowQcL%{c39oCb+(q*pY$^ z4cexg3&sg}f74%qoxK^Ft5EJcf~^+&Zxm7?zg^bz%YbCq@df6S5Xn1>18Tmii`}>u zD7cu+^3DxO&pZyQ{F}zvh*WZ0*s+71bw~VKFRKlO8<%q2P8^bX3L955rv6BB#uLvD z&Aqy*QNXf#>s~SQi@x^$`1@fl1Mx*K2=$(%3q-m0*DA(%6E;()9qF~9^>otT{Sg-p zG=Weo3y5)NLe`Gx`ndpMkS? zim&MHmn%UIFZm-9<2&h0hqUBd!}BX3i_TvF*t7r&1RAo*k6;hVD(t}8%;Ek z@oKy?LHHR^+mo9M#u;tro+D%~wN&?;7t^yuqi@5qwYC?w;{3SpPJwb!f9^GiW-Cd< z&Y6%|3;IjcSA$A+8JSjVXIQhsJqwRZc)3*=qA{FmodLDJnWaZIOB+%wNmoJ#;nnWPC7G5TO8_u?LNVpe`Plwf)xt zOefJcWoMCgj0aV}+(CnwGi!Nx1(U8F6FD|Wq6~Y4a2faB?qdHHhY&rs)gQgCW}7Yo z?1Rf%Z{3wet*?vvg26Nqdt=Q68skE_kYLwoxo>Pj&&qNED|`)JTzX+KXOD2Vb!#=Y z`!=W+doQsYj{r^V#teqEn$c-St?|ak{pbjqH%g>kwV&vzor9%i=pdq~YP94|>7UuO z@Xg8>OB|-9^wbN#yG|GxKVA5;Wk@r7{z4wc%$Mofq#Fj0cyD*vfIn~1{IPgRf-$Nk z1_isSDLL9zL~V^FUPI&dT;I1{QQJD76f)r!0n%(n_dq6q5di8k_aJ6LaHoC}YGFk7 zQe`9rf0woB&jO<+gBu^}A{YPJ)}b`egUwgc;-7|je>H8|Fxdg>Y6FMoB5Z}`k$qPd zLXK}Ft#l307CXM^pDLf@U*2&C6IqfE_fwi?v0+}I0Ho9-8p9f{eID7&VLw4M#-&^z8vXG)&y|(gh+lE>>=g=v`Wt`R#g+E!mwbFASKi- z<*msa(rHo*?s#MYb=brsMKvLFu}kDp3#TQv`7+Hn38ZN=mJ7LCOnbi2WclfzLiNQe z{cK1<(g~I2oMU4U-12l;#1$Zw7lmJRFmuw(o$&&$FxxM^-~!6G>Q(gHiUP3b5^6QG zyavJ{lMAzh;UO8c!YYWlHT+5TwpIG0*mnza zkR}zy)0nJ*o%i%d$!F@T&W?6IDIBzF8Tn{#s!MV(TUQl3|L|)I1Vy&~q2u>z9OBJB z1(Wc5_U$E&XK>!B&4s~hLWG@`ei}QQ{n)BOQ0eEIG46gcH$p@>=?&R&y6d3{=)S`! zEx)rIB1;s)BFnT`$(wX*RY@fkRZ!qT6ZuA>BN5Zckj(WD)mh0r#7O=xPkLBD5l%G% z_>9U63rJ~LEFf(ceTzi%MYErfXp{V|BZ})rs6N{0Mi#y!5c$08R=BX#U}K9@jLL>v z(cG?D=&5=WnE}Iuw#P@|8V)x>-Y~1 zwe>IdZfiY}x$mlAc_MvlQ{g>Ljk$9czkDv?pGYQoub`~gfuX(DMW)OM51W=^b9Jr` z?6?bQnqSrB)m>@^@3nK-fJbQIre$Y27iOIX&$Veu1rpr=aOYg8oYyBBNgl-dr(jwl$I= z=w`iu=#;JW>m|{^Hw=jN3rdTycZ|Jh-W}92Qa03lPB_XZSs|*fVx!u0MXJ zzVnvstQ}>Q12LY5!)3IKmxhNeLe{{eVf8Q0qa>0V$~ud-P}t0i@M#MjARR~mEg7;D z#P9fL?7OcD3Y$YZ%aR8|ZZhwb7K!L9+CqH|rGjTNvKkTE-OP^3AZMf;sVJ}dX~vix z4-MQu|4aKsk=8R$I&QzY?Iq+?OIL=u5aDUK>=(yh>bv7fq|}MHefmwU4GF{C%%vL5 zz%ZEr-0Mu_>whJI%H-PQghprLR|ni(!yf0JDh8e3D_B42k=kb;Ck^h?Ru?%IfyBX0 z&T+_}*})(y)%Bl4d&ySyC?-)>!SdFJ&iG|Ip6Gcdl1dK)t@3i6+9f&nm>+z`)H_D* zAe8%`$!BZ#ZjgbV#>|2M^XjH*35br>J+reKr(OC!w@da7@NNP+egA zTJPYT)I}b4{4Jr{HL$5qX=~tLox#5pr3zV!Fllp4^u52M%%3odb3X7PDy;TpIUebQ zB`<{yN$vJHkG`j8aW$MeHfw6!S8Xt%8+COI>jm0oZl|sB*8@VJD?+7GcKWjMfp9~W`N=@eE?+KI~!af{>eXY%X3XU<7cn!o1K*j!Kj$i3Z8?tuG-cE z;m<$f3C~J|iX#lpIWlV?83HOOBljelC}QnCNhP;)N=Qa>@)^>X30=N)Dnm$Xfk#88 zM2+W!_reO8b+Di_Cz5N*qxOg-f82LRAOG;MInLccX!${k?Y``m5M-!_|DH1n-J`uE zGm57BU>mu?qj=Vg-k1F=D%0Q*Pc?Lz!l{z{V3bzU4<8LHx%~_jk?G_dWYg$mB+J}6 zI_m=Xm$kybWL&_e2v#MWsg}FuCjMRwc!`JNtLnDU8>glW4xaQ2dpo@RlFT$?<@%0b zL!M0W#OuA1RLVmGY=26r`G%@wzkqzu|0s@CibBJe-_(Lx_2eJMPDk!u-kFWeVXk@- zxkct%<;3^TN3s($FVr5N7j8akSmT|`ky6VMcrpxOpwpQ^Zs56)vJ%*(R+5TXuP_fL zmA;zB+{+l+l}|8g@E~X?exWoJb5wd_(=PFUM3RTV>~ENpVH>}<7;FE<;)Nhy{;rD{j>5UPdbk?b4o&b*WX=Mwzak{QoiQh(Lu z8STwNEb>9;R!vH0I!GKQN+O>q-T|%1tQ~hr58Fi2UchGpktAHpR?~ljj?EYhtOx>41Hv!Hjy9eWC zAxnQuAr^fAnM0<>i@>bheW7@CDM|jF2zP8K5(yc1P17o$Zlk0`tXF#HV?h&Dd>bpk z-O7)^t@}2`35X7;beWAF`{8RmOmCoZPT&1Zj{3Vij8ZT7l z3-)W6mvoEs+`-NUS8LMP=x)Saj9MJ>;B6W0xvghIs&9FuUq{P;KbL&` z=ES;y(Qg;dCR!KVdGNX0EqFuLU7hbOPm%IKSTcXboL}AKo85dmsrjfH#vdUWg1!X@>OqnakC>`hQMt-Y?hE0h7$t;0X6efe*Pw<0XV zC_9P}eVd$@WqksJ4Kc*lzcm~ec4q5szk_vspl4QNDq{~3k0bY|evoaH4aBs=B6~b^ULbX&FFc^#ezdy{E~ne(V3V2btndTbun42x3>oB7eIq zpzoU?Uv{E5Rzaf!^mfMEu!o6chpdtUQ+BwuG_vrsjZ#DA;RsM3XF^_ONj~59?`Nxj zTr18r`9u;`v}6!Wn3IXo{8S6I1D(b}#<9XwDoTYGSz)+rW`B&9UFG}lOJ}k!46BB1 z(rZ9D=>uH5QvK*%dfK&a(-iwLUc6dzWh4$6FZs(_S;$v*EoI?5O(~0%by^YGH6eoT zl!@clu6%?yna?Lrn+}<=c-O*Gw@v9@>#K*nGX>8{v`aWGm9sk;v6@7hJ0r;y!!g@U ztArm!HO7_c0{y?keC4^;Xf9D(soDgkWK=VbE--Axs!9W6zktVsJt1O zeN$R|G<6+I4VZhabH}v$s#jeIHk|vZmWuXqeETi-iF0Cp;Q8%R*oyK-iOR6D^_t_i zC&O-s1Q`~1P+?gn=?Qns%<4Y$wGAe?KqhtWsHc!rs1dh9JqgOgm9~3#N%p-TzFHr0 zb^c|)YG`)#JbwAi*Zci^&()tC8~<9_fhU@uy#DKle-_mHj^&Y$+&6aTrp=Rm8^+dR zWC^`@yq|_hNn~!*w4WBBx~_xir3C_pb$Q6X0^UXb(^+W$t{RaRrOigJKzzE|zy15T zWzBKd*CaUJO$@!%zuyXyn)LE?i-!kERaR7I2!;Us67sJ#pPghpf%<&uU@ZrpT zV8gNZhs;){CkN4;?mI8vXv%Ih+r{ipOcB&Stv%ZDu2&fap;!AO^dAio(w1P+J?~qx zNjIm^Mb%a?H3n;!8-OxBIg$k<+oe`xu*EthvYA=j0uEfI5f@84@pDy!{`oixU>?O3LlrkWsVt-K0ftCnelF9sObn zCo&QfT?423*uGC_sX9D(#I|BBi4R@Wv1b()$}S|Eqaxfd^8^b*i;=BTiS6iAo)iY< zyf8PBuc*%<^}PVd-*(Hh8)q-wx-}vHJ{G8l z%pP_ViZiOQ;)9TA2pvMaf| zu+t>Xs#Xh{pX0S)o+!IX()xfL&Qq-^hpek|$w%@Fv2>7I5H=oXJTOk;i{$J!tX z=B9v~p(oi1G%g^0Tz2!qJFzi_!ltXy;^*|0KB)qI7{J1#EYO{zqdNqF&Pk`+(KOhI zCB?th_pP^M{Mxco&c3%hlR)rL-9x9oKUaTSL0jmevz$h#%f^xFm-r~ne+SYC z8w>VQKsoZCakfiVpb~Xe`?5x(!}clgaK6CbT$dVdm4)D)4}-yhq?rO9ye2qTAd6a2 zS}oq1i2oR>v}FgghF!kUQtoKv32_OZAuzCi7BG;k=39+JC>_{y{#g09)#YPXG(-N? zA2hI+%sOoUzK@xpNpBR)_W|Nk_cg*HBekNIFd04V3T}x4i)S&~EIueOLy9uYYJccu zS?29i8Vb%q3Vq6>=MC#MVW(MqqO+C>K&&tYvMIeX#-I;h?vviTm4~jTdphT~wE1|R+&;$d7W%pSAEel_5UEJ~F zSIG&(8`~E4{}tAfpm-~4^KxHmrI(GV}p_N)Y&yNgXf?XM)4>zI+CHCz{GA&D35&9Gmi@CGQ3+)e-E|hu<89{&vIbB+a-k1@X`eVntOF}Q5^K;Bey16jRd5i)? z+oqF}huDj_wooemZo`vrkHUbhF2)1@#uwF@;%UMqvpmdrf=rS}SPLP7dLk}v7%KIc zTEliz99n_SwQ!D!$K@sN?@EZKT_$r12yb{TtTZvgXEXxb1qGcfDTT)tEa^&u z;@}kli(jyw5n&Dre#gx}C{lWdV&!ih)mAPb-;PG?ojy6*{n{0bNW) zf+<^rppFN_>sAG$CCPxIv44JoDL@XPM{|ADB0nY|Cv%^t_WKV6jzS~dMQI*!@M;#s zC?JbQRpUSynOn&?G>!lt1uxF6N4`rl(fdM;-ivG&N!E#q$s9%DJmoI$Q(V}m7%M#L8#{KGLj@)k zHIPga#aO$_T^q$ebcz>k*^2Ua$9)0*)01M59|IWQUb+Xco8PYAF|9tN2`3ByCl(-8U5w!S z#Rz5={Rz|%=~p~W#VXv7B9nfi*jMYvNruN+_5M-x2zV+F%V+O|1GKR&2Nki`j;S9| za+zd?hN^)TgtZ`p%rXd4C_`es>H0ELZ1kwqFmrF))E8d1{e)n1 z(9{LUcaATOmR*MJWcp#-lHTmI5!VgE4pwV}hWuFZL>4ktUHkzCy5c%6_JPE}^Jc`j z4TL4IMQ3H?+ zWbj1po0#;vFYED3=K$IJWUKTXp1_qLhBg{fVWt(pLf$ME^4e8;xC%n&P$jEj1{F4Q z%Tzv{z6twK3^@aWjRw`g)%HuaUJE;O1Av;Yzx)w2l_j&*w~a77y6dsH9PnPLV(tSD z1R!OWq=XmUWH(F=FY4fQt8yM|9XJ)-a<5bPr>H-s5@UXS!q4-AqyR01BA^E7@CNtlxkce~3{ z+Q$Qfu#oEX1Pb*Hx;5j43V5MroqH`o((2lXnA>L2)7myA&ejnVDMkOm+I#1z_C0^> z*ozGG(uVXnl2>t3{u9+x>XEVU_ZA=a()W$9mS`TM?2=>&Xli0|5OUL^de&^)uckGR zQ2|@RLVY~@4H-aUw6?1lT`+&K_iLn3AFmNtTS0nQSCD3%>@LvX1meJAfDnLq#~ore zhj|&%BeVqEZ8qzJ1%kxOTJ)2EG+2~dL@^DEvRKHLqa%kXO6wDcLW<#nnI zR1$v9>$D@+LZlIQ6C;n6!hGk``Zd}-OP1I%5@CWtg4rMfxx|*Wz`$ka+{NNzKw0^) z1Wfk9xXAo7Qx^3mtIAq&mhz7D05pnyv7L4WXpYMOG$oqnuFvmNzIK%sTNpj!lmEC+ zRGga+$tKRA}t+$J&$$L^>BVP{AXiA zv&O=h&|+CrmteKKi{R76u$6YegSh>8h)iE|pPU1x^JB{8sX3=~qg(qkmTKkeFuo=t zpl=E*RSRM8$(r)Kp{g1Ukp!_8z1c6lno0M}VrezV9&4g_pws&X!)gBMG)qW)uD~!W zb>hRa#JT2aBG}J$H>Cu;+3Z`e(FljWOgMD$KR})_EV)u#8g*l?Q})o!XEQy}43w2b zW)isfcTrg!%t^1&h?&P@>5r^)AiC_P3Q%4oh-cv`3DUh8Weu&_B5`i9E@rm8R+F6C z#y4QLymK72KfmxDfqLYSH`4mP9WWBdr?htLIp;-UNclT?X`WRQg(6B+k`g^xa05!C zK_690qll&PglxlTj8@1tOi7J5Q-X7Yr5&DLNPC=JfIZHbqsF^gkva>JtJ0Yn)FvM>-@vY@+v0`IL z?KWn>0%badrgnQDQnAbkm0lE?Zbmh(8fB7ct>xL`tKm<}z`!b}oJ@}>U!{O&@ticB z3*`^RPSMinQ9RyGtBMUBEV2*6vSDc&0=(D@OcAx5EYh_$nAWwbn^i+?Iipscc3d?o zkn(8;cY$LWffU@~zyOhiVhl(G8ZTP4eJ_uN=_flC)(Mt;2lxrs3USQI8j)5J@wk~k z#+Q)NpZ)Pw#NV7UvYl+p;(Qrn<>&e!E%UZSWau>ysjJOer_093E+9kKD5dmI67ywf zC_0zJ;>41KO{(+L>M1&3#Is05va$|G=Diqmvt#6?U!w{#v?0MgkP-|6v!9!hT^h&o^FVOyGGhnJ@!;tWRSJ%321N?f@8?p_kfMtuKN_5%KIaq5$Css=D=-!B+ zgd>J^BOt5I2*?@-OOdQ0grI4}FVL;Bu!`wD>Z+Wy0Ew|Rx@n{r0j1bv62iOBlUU`_ zA2nInjIqjP$#t{Jr9Wzw>q?Te7xHB4SSMw()^;W1D-$tkvY4AYL`F#=RmJv_#M zt|l@I(hQGrQ0qo*MZzr1?PLK{B@Y<{>0Ba3q)gZn|Ts zQqG3%(63-gGnAQtuQX7{C?|Wxn4=W;Nf_J>#R)2jE+ERTZPIhkt^og0KTCKEMG54eS6&~^15+)!Vu*xF<;=l z@c|8C(MwGH`?6n`IyVx&<|GTIqq{5Oh#2}p3D=AMc!j-aTD4kqeXF7C)t9w|;qDH0 zaw+X=9E{Veq#=nE8kS54D0L#zRg2rM(PIjf+x}qzW5{f)21wIzM2wT(5Gz`X48@A} zj9xro)%2B0BECcFg3?eg=frhFT}Zv)qE?;;DC0l&fs~vhGw>z&z*?MRa{DM_bGCK& zS9YTZ~x@b$%*0PNX(gO^)ny4r>PP$ia)k}u@ZR@iBbGUzNCB1ih8htPJP zZ!4m~x_Mbro4kU9Kdv@38oD>bxHpZ4_AYAU(bFz!BV{=z!A=_zPEO}uvOHuEp6$}C z8GVHWbKP>Q(|nX}&6F;bdLB`)#9ViLhg*xP@f4cjswHBE%Nx8-i1q0Hi&D4P=XCnS zx+AX~56espaE$Z<(?`Q<6ih3ab%Qc=Z+3ZRi+R)}b}Z$NQMl^^|fJwj68pvY%eRe z+=0OTUcbsWUkTYUVndSH(pDUh)l&<2-vZ5S{mKdh@+*mpF!e)fO_kr0(X{afhtPe( zQ>Y{i6QKq%yJjL@9Dp6?j(6^>BX_I?THfC=6qubQtrlKzQ%bbyq(Dv2gXN!|)ig|2 z6C;iZl)IOwsnXQsb#pQ)$05lWS}}>BALDECpl5UDdrb|WcvW3WJw0G8ga8|~Od6>z z#bD=q~)!EhVW;2`01b{_-6oBPP>p&<2 zz$!M0!BQ(RF$~4YlJGvbgcFFk0~1fx7h>;Z-xfvGV|mnTGtF?{mS(yEu#p=9SeBNi z!i(6v;;?kzv>^dt{gG8kY|{F~Uy+IHl4j?Yn*rc%%@_bS^V9`^wLc2L-88=&0Be6- zE5diX{#8+4vuH`^j?M|FrmUHQ>a4ojq(gDGskMt7Q62ZUQD3k3lU|DC^%? zBbl^isO_!|3?H#Nt}){ssYqiQTXMpjLfD+Fefod&WfV4q&gUy;&75`rpr%sfP&J`) zF*6KN7xU8F?kI2E&yf>{ej$+-r#WT7T4!zLMi$&9-PHC{T^@#*2lYmICg*HJ5AYe! zCd>&yEOBb4Uoxh7sO{KnOgS4XF#@Asfb%iTCsKAs+E$rhck$uaHW2BQRt&PzjXvae z6&Gj9Gmj!JhHB0D>sr^|_f8h8I(rny&B4D;OOdkGdQ*D2V(q7>HDxXsa#duEd0x|| zb(7xD{y=!{DVAU$r>SXKCN|_CR3AEz72^V$9#b<*Q2n+TQrK}Qz}1<1q%bpJm#fW{>B{c>s=fU5rQD?U zdc+P3J3G=`mzrIVoLb|`Dfsw&KxbP(XDxr>+6%=GLfI;O8n+sBOuD_PPcSJHSs2MZ zQGz=q>S`S|3TTaf!mZMk;VC4Be8aCm1kWcGy8f$f#k~X#YwCg_Uo2Qudo%a&ovgMs ze@p1-UtVy~t1loQ2=iY|=+>I4~<_rxU z0TCuhf-Pq|8&X8lq$|_R$b!pIlo^LJX#cNv^59SUMR4=|@wA3?}eyV<1E%)j$?x^i4i#?Oa5t57%WBxyiyO1$*`krO3TIBwYA2 zH>=V@H`-*1ENjs=z=jW}0miN@K;TKTP4q!jL!J~>K)KJ7w$5ALjcU9xDHvuo4j=>7 za^vMZ+6k$}u8CVZ8+*+fM^j$S^)$LhiRMheOUzuwIe%)=F&TiI5KG#*xgN15-$^@Qk!Lsrfp~lS}CTX;zBNkz+iD6e(o4&V&012PU@2*>&l|#boCX8G!87 zP>}g|n!=&`oT!|@ja{1IWV%PgNq$D%-rtt@J0=7~o-JF8MAGK|!m&_NRuGAyZOTz1 zelqG1oD#;|elgqmQm(%j;=^YBk!3=T>MBMw%37->=S$zDhC97_9!GazpKO7sfr?=a zU1Z`O3@O#heOZw+T37d!Y=keW%R6M`bvQFo4E@lX3vnyofHq5XU|Wnj!?6BkxUu&2 zZ5i$7&(&Xty6#t<+*-vdfoBJUGZV%u34?VgWNnlQ&9AHKMgeUy`xya`Xe(&H{B=+ zJt=c4U~uBo%>wV0Trr0S3K{ zHE3Ys+M7x1>uqyCoLdHsa1tV2rtebMF)V8xT4<4El6})vpDc%wyoDJiIdEBmh$S^x z0d2aPMj}7@=b?m$>y@C5(1ll$UZFl~k=sanBQ6_q8wK(FrI6b_gIPY$n5Cqd zOpZdR&ntnuMpKw z=@jd07za0TKvUHu6_b)v4c;S3>?$Y+J5HnD3{Ufav^Z=QPMwr8GOf+iA}jZkv*ebm zDF~XvH#NMxmZ$AQ_}?Mv#bBqg37R}B&ClbwOpdessU;!uphZ*i(1!ck(Hy5PD5_m`kHXR6j*^>_aOko8@|Ex_ncMX) zc?8XdpPFpG`oh%l=~sDhdRY4{?$)Jb;FhdE;4nBG3V#AWKfg0+=Tq|X zGP>$mQw4+nmX4el8{B7d-mT&Ns*nJwPw&dAAAQRcpyzYQA;A)DO!lop+|_~LAFC0{ zclL`unvRSJ{dV{Z59kIF*Bbfe=V?Eqg;_JYU4se4?DDXuX5zpl|JKzE-3kly{`I~ki>hCG z@2YDiu5kVJj`7v`y-y`S^M}9wGUAu#=EfX}oc?wCFSZ=MrsVv)tKWGd>**yI?<(y( zr6nwQkuzxQO2B>XzmNFm$;|*4<3vOzYk{JSfY(@ z%na6tapO(C@89-??HXf_LtQU-hDEQ0Bz3%}n%vhsPPu-uHTJKoKTtC`@CFj<`jQjE z&TGfqPi{|sO-#~vljk9rq1|58*5BL>>72}4)mRiMU5=i2XQ32?_bf6q zR@pbPO%9LseStX4uGa0zA<~N$pgW1?$%O-d@MM?ygGD7pLgj%zcgvP=mk$n^g=CM5 zfq+@S$W(t~qK!_Vmnmg-hnE)kjxenpKK6l?ck&vOv*}V#{_JKmzF^<*@ZDxa_x7ge z5#3|t-IK8)m?@%vsTz29cBRqd*W@nOV^!v3&PqjVsc6H*s?1{jexwz++eMEVIV{b+ zOYC&0Cpu^*b3LtXogtr8w;r?Oz`|terJ12gQ7E1{I|{`!uAO1qUoW(|He?iTE<@`0 zoRKHC4hf1ZE&G7g9940<{abN`PDal9+;;OV5&`Xw1;uIl?J|_bIGwk%EM`e{)XUq) zID%~@woa_k%+%;XXX1>_XsFho^)=;(bM8Q) zPDErXGvlzHA_AdzfzBbYe3;{J_b+PPuRu*FF077UEp~E6fk#8Criz< zR><1t_oY;Buwj9grZdHn$WLAcTP1+ISSOX!Jp z06urO%!7eN)`PK_gdjl}8(l|su6V+V5@4z5LST`u4I(ld@kZp$0CGWOnR{db#&gKu|OpHU|*djWt&F!##^itSb*+UzL3Lpz7=altV6z z-c)8j?)GnQk~@Ey*d_e}Vn^6>`|!Qmo7`C5-+1Nb#x*#QkG(<2<1g5dsfvg@SXyaE zMISMez4DqMdY(lxyF#C7Qa zK9O*d>;R%T=y}|H-v4+uZ)xs%o)XUYoj)&GdD*Ek)qOb9`%!|hcHXeb|I~jljKn4e6 z0NG`K>=w!L2duJSrnKzB`a;?%kJ;9NtZ+IfvtWj>8Y^(E&80>kU6&46U0M>RaQzSp z6{|wG^X^JEoJ+WVIGpt|fZ!)!r#qedc||7SdJyU#qoZkM4C7=G6y6@e<;I!-=R_R( zchcNUX-9SGs;_1?xFL3?EqP7-ecR2Yo zMl4;KL(9_jJXoxlz@iGZ3g@c%s4L+kBz1YZlJ7Y0&oSWHxLoKa)klUz)+KdaK{&(nzC7Qj%Y*h?44#IU_xYr+W55EXZDS zJ_p~vfTZx*#R;r1?-?7Ta>i`r5_;|)k(IyV@1;QuBh&t@Pp+wHF=w-hY1 zK;$0W;RyW{45wBohO4f8Fa*#wi1cgof$1CDh$-X->{S1WO$Hp0ECi`dfA zHw#ur-c2C=;rwlY2i0Z%UdKI5Y@|%iPSAaj1D5?-R+9IAZM4AMO1J&m43B`=O|h_F zdt075(GPnh#{F96WPwSRUZ}qh7mcSbb z*s8n$L8QQ!^I(wE57dQab}kD=GQZoTy`Hxo5+j+0O+2mXil<2L_Qq3v*22>UFFf_1 z!FwwK;zw;Xe+Omnr7`iC?Ow5sPqHqCFTJ@RY;FOEk(?-caWlmVVII1{#`%(=A1sO3 zv|E{NE_N$G^v)!GFqa^`Uhaqlh$3;2Ak8K{AkB1kCw2`5X|W~OUNoIC-Sc}2+;y)z7qV~`P&NL9tXS_ME0jU^lPx~$iZHxA zOn$RKtrSs{W5(BR2oVpH^QPugzE}|AVTy=7>zwODzZIP301Qr#rVZfnFy&*;${MLB z6nlnm7ZcEU!qS^lO=odPo{B{5I?DTf7Y=B2#g}8 zgE{Hnu6cwb@1|9+4@kpSA7L&NYfd%T&4<4?@YU5Z__v6&H(k2b)bv7RXl35er>=rWX36zBpur zBGUkK^h&-+7JN160nc0p+kUi_ba_7K`5Fhe3aN>C{oPutwrp|{Ep(&NG&HMjgoREL zLsg6)o(azu#t_Ev6GS;ho4 z1Zn5$pQlL0iUi475K8IDF-B}Tax|^RZL!0|GVksQ-}WtM96eLaK6X9jBVp4JEI9g6 z4!3L(U3Wj|drF8)uLtj0#QjeSZeWdjx=dR-=Xshm6s={_5W%Hq`=HA*I8klvsa*d2 z#ux-5vY_kLF-0~6Z4CCyFbkYXWfny1j!mG6MIW5(o7_UCE6((b^cYE{j9o6N7K^BqC*5RiKY|1pv zO8hZ7c^0-V7xO!kj{KEHI?4pfZXz2VB`R!`QLTo)-a8{#0PwVi3!7TC0Mu-rTa}Wu z=rQnyLY@sGa(uVLDzFI^28Krr=rX#C)}^S{XK?NoM!){9X(!6ejM}y`z;q`eP9$|s z*bC8ei|#e%vh+t;B$>_LWUNJ`7K_APn~hkzwm2hvR-7hkYZs@fTPt2p6E#8?h-}6x z6`{AxTJd)Fjm&73c2`1=)1=5_h-Z>k{FRCSyyWfx(hVE5W7Nh$I zs;pLa7gfG}L@dyB6H3FAZrj#bTbGsIsO+qovhT1mgJ6p)7@>p_mLxD6O1Myg*`kEa zvlbp=rI8t$=u* z50;_!fpYQa%%moqz`N&8f`eFYQ}PIs$0U z{C`37GE7AQry`nX&UxAwxDyIZ+2F>Y#)G_Di|lz9+g2+Hn%0AKHI)QJZ4qK%5u;3& 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

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

_gT>~S_7^L=qPJ1N3vINtx2 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 ToCaWEkOsmpK-N#7G6n_!z{1~7 literal 0 HcmV?d00001 diff --git a/main/urls.py b/main/urls.py index 70a515c41..da2d1a36d 100644 --- a/main/urls.py +++ b/main/urls.py @@ -199,6 +199,7 @@ router.register(r"simplified-eap", eap_views.SimplifiedEAPViewSet, basename="simplified_eap") router.register(r"full-eap", eap_views.FullEAPViewSet, basename="full_eap") router.register(r"eap-file", eap_views.EAPFileViewSet, basename="eap_file") +router.register(r"eap/global-files", eap_views.EAPGlobalFilesViewSet, basename="eap_global_files") admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin"