diff --git a/assets b/assets index 5401e78d8..5f43fe614 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 5401e78d8e894f5722a7a072ea2ab9e7e7316566 +Subproject commit 5f43fe614179d2028e30ff54fd2fdbe760ffc567 diff --git a/eap/admin.py b/eap/admin.py index 9c2d1ace5..5977ec168 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -122,9 +122,9 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): readonly_fields = ( "cover_image", "partner_contacts", - "hazard_impact_images", - "risk_selected_protocols_images", - "selected_early_actions_images", + "hazard_impact_files", + "risk_selected_protocols_files", + "selected_early_actions_files", "planned_operations", "enabling_approaches", "parent", @@ -194,7 +194,7 @@ def get_queryset(self, request): @admin.register(KeyActor) class KeyActorAdmin(admin.ModelAdmin): - list_display = ("national_society",) + list_display = ("partner",) @admin.register(FullEAP) @@ -218,19 +218,19 @@ class FullEAPAdmin(admin.ModelAdmin): "planned_operations", "enabling_approaches", "planned_operations", - "hazard_selection_images", + "hazard_selection_files", "theory_of_change_table_file", - "exposed_element_and_vulnerability_factor_images", - "prioritized_impact_images", + "exposed_element_and_vulnerability_factor_files", + "prioritized_impact_files", "risk_analysis_relevant_files", - "forecast_selection_images", - "definition_and_justification_impact_level_images", - "identification_of_the_intervention_area_images", + "forecast_selection_files", + "definition_and_justification_impact_level_files", + "identification_of_the_intervention_area_files", "trigger_model_relevant_files", - "early_action_selection_process_images", + "early_action_selection_process_files", "evidence_base_relevant_files", - "early_action_implementation_images", - "trigger_activation_system_images", + "early_action_implementation_files", + "trigger_activation_system_files", "activation_process_relevant_files", "meal_relevant_files", "capacity_relevant_files", diff --git a/eap/dev_views.py b/eap/dev_views.py index 1097757d3..21804cdb0 100644 --- a/eap/dev_views.py +++ b/eap/dev_views.py @@ -17,8 +17,8 @@ def get(self, request): "resubmission_of_revised_eap": "email/eap/re-submission.html", "feedback_for_revised_eap": "email/eap/feedback_to_revised_eap.html", "technically_validated_eap": "email/eap/technically_validated_eap.html", - "pending_pfa": "email/eap/pending_pfa.html", "approved_eap": "email/eap/approved.html", + "project_agreement_signed": "email/eap/project_agreement_signed.html", "reminder": "email/eap/reminder.html", "share_eap": "email/eap/share_eap.html", } @@ -97,13 +97,13 @@ def get(self, request): "national_society": "Test National Society", "disaster_type": "Flood", }, - "pending_pfa": { + "approved_eap": { "eap_type_display": "FULL EAP", "country_name": "Test Country", "national_society": "Test National Society", "disaster_type": "Flood", }, - "approved_eap": { + "project_agreement_signed": { "eap_type_display": "FULL EAP", "country_name": "Test Country", "national_society": "Test National Society", diff --git a/eap/factories.py b/eap/factories.py index dfc0dcf7f..0714d1ab2 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -62,6 +62,7 @@ class Meta: status = fuzzy.FuzzyChoice(EAPStatus) eap_type = fuzzy.FuzzyChoice(EAPType) national_society_contact_name = fuzzy.FuzzyText(length=10, prefix="NS-") + national_society_contact_title = fuzzy.FuzzyText(length=10, prefix="NS-Title-") national_society_contact_email = factory.LazyAttribute(lambda obj: f"{obj.national_society_contact_name.lower()}@example.com") @factory.post_generation @@ -83,18 +84,13 @@ 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(2001, 100000) + total_people_targeted = fuzzy.FuzzyInteger(2001, 100000) seap_lead_timeframe_unit = fuzzy.FuzzyInteger(TimeFrame.MONTHS) seap_lead_time = fuzzy.FuzzyInteger(1, 12) - operational_timeframe = fuzzy.FuzzyInteger(1, 12) + activation_timeframe = fuzzy.FuzzyInteger(1, 12) national_society_contact_name = fuzzy.FuzzyText(length=10, prefix="NS-") + national_society_contact_title = fuzzy.FuzzyText(length=10, prefix="NS-Title-") 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") prioritized_hazard_and_impact = fuzzy.FuzzyText(length=50, prefix="prioritized-hazard") risks_selected_protocols = fuzzy.FuzzyText(length=50, prefix="risks-selected-") selected_early_actions = fuzzy.FuzzyText(length=50, prefix="selected-early-") @@ -208,6 +204,7 @@ class KeyActorFactory(factory.django.DjangoModelFactory): class Meta: model = KeyActor + partner = fuzzy.FuzzyText(length=10, prefix="Partner-") description = fuzzy.FuzzyText(length=5, prefix="KeyActor-") @@ -216,20 +213,16 @@ class Meta: model = FullEAP expected_submission_time = fuzzy.FuzzyDateTime(datetime(2025, 1, 1, tzinfo=pytz.utc)) - lead_time = fuzzy.FuzzyInteger(1, 100) + lead_timeframe_unit = fuzzy.FuzzyInteger(TimeFrame.MONTHS) + lead_time = fuzzy.FuzzyInteger(1, 12) total_budget = fuzzy.FuzzyInteger(1000, 1000000) readiness_budget = fuzzy.FuzzyInteger(1000, 1000000) pre_positioning_budget = fuzzy.FuzzyInteger(1000, 1000000) early_action_budget = fuzzy.FuzzyInteger(1000, 1000000) - people_targeted = fuzzy.FuzzyInteger(10001, 10000000) + total_people_targeted = fuzzy.FuzzyInteger(10001, 10000000) national_society_contact_name = fuzzy.FuzzyText(length=10, prefix="NS-") + national_society_contact_title = fuzzy.FuzzyText(length=10, prefix="NS-Title-") 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): diff --git a/eap/migrations/0006_remove_eapregistration_pending_pfa_at_and_more.py b/eap/migrations/0006_remove_eapregistration_pending_pfa_at_and_more.py new file mode 100644 index 000000000..de924e12d --- /dev/null +++ b/eap/migrations/0006_remove_eapregistration_pending_pfa_at_and_more.py @@ -0,0 +1,105 @@ +# Generated by Django 5.2.14 on 2026-07-24 04:49 + +from django.db import migrations, models +from django.db.models import F +from django.db.models.functions import Abs + + +def backfill_partner_from_national_society(apps, schema_editor): + """ + Preserve the old FK's name instead of dropping it once national_society is removed. + """ + KeyActor = apps.get_model("eap", "KeyActor") + for key_actor in KeyActor.objects.select_related("national_society").iterator(): + key_actor.partner = key_actor.national_society.society_name + key_actor.save(update_fields=["partner"]) + + +def clamp_negative_values_to_zero(apps, schema_editor): + """ + PositiveField adds a DB-level `>= 0` CHECK constraint on PostgreSQL; + existing negative rows would make the AlterField operations in the next + migration fail. + """ + PlannedOperation = apps.get_model("eap", "PlannedOperation") + SimplifiedEAP = apps.get_model("eap", "SimplifiedEAP") + FullEAP = apps.get_model("eap", "FullEAP") + + PlannedOperation.objects.update(people_targeted=Abs(F("people_targeted")), budget_per_sector=Abs(F("budget_per_sector"))) + + for Model in (SimplifiedEAP, FullEAP): + Model.objects.update( + people_targeted=Abs(F("people_targeted")), + total_budget=Abs(F("total_budget")), + readiness_budget=Abs(F("readiness_budget")), + pre_positioning_budget=Abs(F("pre_positioning_budget")), + early_action_budget=Abs(F("early_action_budget")), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0005_remove_fulleap_unique_full_eap_version_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='eapregistration', + old_name='approved_at', + new_name='project_agreement_signed_at', + ), + migrations.RenameField( + model_name='eapregistration', + old_name='pending_pfa_at', + new_name='approved_at', + ), + migrations.AlterField( + model_name='eapregistration', + name='project_agreement_signed_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when the project agreement was signed.', null=True, verbose_name='project agreement signed at'), + ), + migrations.AlterField( + 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='keyactor', + name='partner', + field=models.CharField(default='', help_text='Name of the partner organization or entity.', max_length=255, verbose_name='Partner'), + preserve_default=False, + ), + migrations.AddField( + model_name='eapregistration', + name='disaster_sub_type', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Disaster Sub Type'), + ), + migrations.AddField( + model_name='fulleap', + name='include_rcrc_climate_center', + field=models.BooleanField(default=False, verbose_name='Include RCRC Climate Center.'), + ), + migrations.AddField( + model_name='simplifiedeap', + name='include_rcrc_climate_center', + field=models.BooleanField(default=False, verbose_name='Include RCRC Climate Center.'), + ), + migrations.AlterField( + model_name='eapregistration', + name='status', + field=models.IntegerField(choices=[(10, 'Under Development'), (20, 'Under Review'), (30, 'NS Addressing Comments'), (40, 'Technically validated by VC'), (50, 'Approved'), (60, 'Project Agreement Signed')], default=10, help_text='Select the current status of the EAP development process.', verbose_name='EAP Status'), + ), + migrations.AddField( + model_name='operationactivity', + name='activation_one', + field=models.BooleanField(blank=True, null=True, verbose_name='Activation 1'), + ), + migrations.AddField( + model_name='operationactivity', + name='activation_two', + field=models.BooleanField(blank=True, null=True, verbose_name='Activation 2'), + ), + migrations.RunPython(backfill_partner_from_national_society, migrations.RunPython.noop), + migrations.RunPython(clamp_negative_values_to_zero, migrations.RunPython.noop), + ] diff --git a/eap/migrations/0007_keyactor_partner_positive_integer_fields.py b/eap/migrations/0007_keyactor_partner_positive_integer_fields.py new file mode 100644 index 000000000..3a5255774 --- /dev/null +++ b/eap/migrations/0007_keyactor_partner_positive_integer_fields.py @@ -0,0 +1,421 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("eap", "0006_remove_eapregistration_pending_pfa_at_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="keyactor", + name="national_society", + ), + migrations.AlterField( + model_name="keyactor", + name="partner", + field=models.CharField(help_text="Name of the partner organization or entity.", max_length=255, verbose_name="Partner"), + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Early Actions Budget (CHF)"), + ), + migrations.AlterField( + model_name="fulleap", + name="people_targeted", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="People Targeted."), + ), + migrations.AlterField( + model_name="fulleap", + name="pre_positioning_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Pre-positioning Budget (CHF)"), + ), + migrations.AlterField( + model_name="fulleap", + name="readiness_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Readiness Budget (CHF)"), + ), + migrations.AlterField( + model_name="fulleap", + name="total_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Total Budget (CHF)"), + ), + migrations.AlterField( + model_name="plannedoperation", + name="budget_per_sector", + field=models.PositiveIntegerField(verbose_name="Budget per sector (CHF)"), + ), + migrations.AlterField( + model_name="plannedoperation", + name="people_targeted", + field=models.PositiveIntegerField(verbose_name="People Targeted"), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="early_action_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Early Actions Budget (CHF)"), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="people_targeted", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="People Targeted."), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="pre_positioning_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Pre-positioning Budget (CHF)"), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="readiness_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Readiness Budget (CHF)"), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="total_budget", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Total Budget (CHF)"), + ), + migrations.AlterField( + model_name="eapregistration", + name="national_society_contact_title", + field=models.CharField(default="N/A", max_length=255, verbose_name="national society contact title"), + preserve_default=False, + ), + migrations.AlterField( + model_name="fulleap", + name="national_society_contact_title", + field=models.CharField(default="N/A", max_length=255, verbose_name="national society contact title"), + preserve_default=False, + ), + migrations.AlterField( + model_name="simplifiedeap", + name="national_society_contact_title", + field=models.CharField(default="N/A", max_length=255, verbose_name="national society contact title"), + preserve_default=False, + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_delegation_focal_point_name", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_delegation_focal_point_email", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_delegation_focal_point_title", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_delegation_focal_point_phone_number", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_head_of_delegation_name", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_head_of_delegation_email", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_head_of_delegation_title", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_head_of_delegation_phone_number", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_global_ops_coordinator_name", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_global_ops_coordinator_email", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_global_ops_coordinator_title", + ), + migrations.RemoveField( + model_name="fulleap", + name="ifrc_global_ops_coordinator_phone_number", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_name", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_email", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_title", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_phone_number", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_name", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_email", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_title", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_phone_number", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_global_ops_coordinator_name", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_global_ops_coordinator_email", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_global_ops_coordinator_title", + ), + migrations.RemoveField( + model_name="simplifiedeap", + name="ifrc_global_ops_coordinator_phone_number", + ), + migrations.RenameField( + model_name="simplifiedeap", + old_name="operational_timeframe_unit", + new_name="activation_timeframe_unit", + ), + migrations.RenameField( + model_name="simplifiedeap", + old_name="operational_timeframe", + new_name="activation_timeframe", + ), + migrations.AlterField( + model_name="simplifiedeap", + name="activation_timeframe_unit", + field=models.IntegerField( + blank=True, + choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], + default=20, + null=True, + verbose_name="Activation Timeframe Unit", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="activation_timeframe", + field=models.IntegerField(blank=True, null=True, verbose_name="Activation Timeframe"), + ), + migrations.RenameField( + model_name="simplifiedeap", + old_name="hazard_impact_images", + new_name="hazard_impact_files", + ), + migrations.AlterField( + model_name="simplifiedeap", + name="hazard_impact_files", + field=models.ManyToManyField( + blank=True, + related_name="simplified_eap_hazard_impact_files", + to="eap.eapfile", + verbose_name="Hazard Impact Files", + ), + ), + migrations.RenameField( + model_name="simplifiedeap", + old_name="risk_selected_protocols_images", + new_name="risk_selected_protocols_files", + ), + migrations.AlterField( + model_name="simplifiedeap", + name="risk_selected_protocols_files", + field=models.ManyToManyField( + blank=True, + related_name="simplified_eap_risk_selected_protocols_files", + to="eap.eapfile", + verbose_name="Risk Selected Protocols Files", + ), + ), + migrations.RenameField( + model_name="simplifiedeap", + old_name="selected_early_actions_images", + new_name="selected_early_actions_files", + ), + migrations.AlterField( + model_name="simplifiedeap", + name="selected_early_actions_files", + field=models.ManyToManyField( + blank=True, + related_name="simplified_eap_selected_early_actions_files", + to="eap.eapfile", + verbose_name="Selected Early Actions Files", + ), + ), + migrations.RenameField( + model_name="simplifiedeap", + old_name="people_targeted", + new_name="total_people_targeted", + ), + migrations.AlterField( + model_name="simplifiedeap", + name="total_people_targeted", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Total People Targeted."), + ), + # --- FullEAP: _images -> _files --- + migrations.RenameField( + model_name="fulleap", + old_name="hazard_selection_images", + new_name="hazard_selection_files", + ), + migrations.AlterField( + model_name="fulleap", + name="hazard_selection_files", + field=models.ManyToManyField( + blank=True, + related_name="full_eap_hazard_selection_files", + to="eap.eapfile", + verbose_name="Hazard Files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="exposed_element_and_vulnerability_factor_images", + new_name="exposed_element_and_vulnerability_factor_files", + ), + migrations.AlterField( + model_name="fulleap", + name="exposed_element_and_vulnerability_factor_files", + field=models.ManyToManyField( + blank=True, + related_name="full_eap_vulnerability_factor_files", + to="eap.eapfile", + verbose_name="Exposed elements and vulnerability factors files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="prioritized_impact_images", + new_name="prioritized_impact_files", + ), + migrations.AlterField( + model_name="fulleap", + name="prioritized_impact_files", + field=models.ManyToManyField( + blank=True, + related_name="full_eap_prioritized_impact_files", + to="eap.eapfile", + verbose_name="Prioritized impact files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="forecast_selection_images", + new_name="forecast_selection_files", + ), + migrations.AlterField( + model_name="fulleap", + name="forecast_selection_files", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Forecast Selection Files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="definition_and_justification_impact_level_images", + new_name="definition_and_justification_impact_level_files", + ), + migrations.AlterField( + model_name="fulleap", + name="definition_and_justification_impact_level_files", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Definition and Justification Impact Level Files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="identification_of_the_intervention_area_images", + new_name="identification_of_the_intervention_area_files", + ), + migrations.AlterField( + model_name="fulleap", + name="identification_of_the_intervention_area_files", + field=models.ManyToManyField( + blank=True, + related_name="+", + to="eap.eapfile", + verbose_name="Intervention Area Files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="early_action_selection_process_images", + new_name="early_action_selection_process_files", + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_selection_process_files", + field=models.ManyToManyField( + blank=True, + related_name="early_action_selection_process_files", + to="eap.eapfile", + verbose_name="Early action selection process files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="early_action_implementation_images", + new_name="early_action_implementation_files", + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_implementation_files", + field=models.ManyToManyField( + blank=True, + related_name="early_action_implementation_files", + to="eap.eapfile", + verbose_name="Early Action Implementation Files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="trigger_activation_system_images", + new_name="trigger_activation_system_files", + ), + migrations.AlterField( + model_name="fulleap", + name="trigger_activation_system_files", + field=models.ManyToManyField( + blank=True, + related_name="trigger_activation_system_files", + to="eap.eapfile", + verbose_name="Trigger Activation System Files", + ), + ), + migrations.RenameField( + model_name="fulleap", + old_name="people_targeted", + new_name="total_people_targeted", + ), + migrations.AlterField( + model_name="fulleap", + name="total_people_targeted", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Total People Targeted."), + ), + ] diff --git a/eap/models.py b/eap/models.py index 26c5fdd1b..86c158b50 100644 --- a/eap/models.py +++ b/eap/models.py @@ -386,6 +386,8 @@ class OperationActivity(models.Model): # Make sure to keep them in sync. activity = models.CharField(max_length=255, verbose_name=_("Activity")) timeframe = models.IntegerField(choices=TimeFrame.choices, verbose_name=_("Timeframe")) + activation_one = models.BooleanField(verbose_name=_("Activation 1"), null=True, blank=True) + activation_two = models.BooleanField(verbose_name=_("Activation 2"), null=True, blank=True) time_value = ArrayField( base_field=models.IntegerField(), verbose_name=_("Activity time span"), @@ -468,8 +470,8 @@ def get_sector_ap_codes(cls) -> dict[int, list[str]]: } 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)")) + people_targeted = models.PositiveIntegerField(verbose_name=_("People Targeted")) + budget_per_sector = models.PositiveIntegerField(verbose_name=_("Budget per sector (CHF)")) previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) indicators = models.ManyToManyField( @@ -592,12 +594,10 @@ def __str__(self): 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", + partner = models.CharField( + verbose_name=_("Partner"), + max_length=255, + help_text=_("Name of the partner organization or entity."), ) description = models.TextField( @@ -637,16 +637,16 @@ class EAPStatus(models.IntegerChoices): EAP can be changed to UNDER_REVIEW once comments have been addressed. """ - TECHNICALLY_VALIDATED = 40, _("Technically Validated") + TECHNICALLY_VALIDATED = 40, _("Technically validated by VC") """EAP has been technically validated by IFRC and/or technical partners. - IFRC can change status to NS_ADDRESSING_COMMENTS or PENDING_PFA. + IFRC can change status to NS_ADDRESSING_COMMENTS or APPROVED. """ - PENDING_PFA = 50, _("Approved(Pending PFA)") - """EAP is in the process of signing the PFA between IFRC and NS. + APPROVED = 50, _("Approved") + """EAP has been approved by IFRC and/or technical partners. """ - APPROVED = 60, _("Approved") + PROJECT_AGREEMENT_SIGNED = 60, _("Project Agreement Signed") """IFRC has to upload validated budget file. Cannot be changed back to previous statuses. """ @@ -681,6 +681,14 @@ class EAPRegistration(EAPBaseModel): on_delete=models.PROTECT, help_text=_("Select the disaster type for which the EAP is needed"), ) + + disaster_sub_type = models.CharField( + max_length=255, + verbose_name=_("Disaster Sub Type"), + blank=True, + null=True, + ) + eap_type = models.IntegerField( choices=EAPType.choices, verbose_name=_("EAP Type"), @@ -762,9 +770,7 @@ class EAPRegistration(EAPBaseModel): 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_title = models.CharField(verbose_name=_("national society contact title"), max_length=255) national_society_contact_email = models.CharField( verbose_name=_("national society contact email"), max_length=255, @@ -802,11 +808,11 @@ class EAPRegistration(EAPBaseModel): verbose_name=_("approved at"), help_text=_("Timestamp when the EAP was approved."), ) - pending_pfa_at = models.DateTimeField( + project_agreement_signed_at = models.DateTimeField( null=True, blank=True, - verbose_name=_("pending pfa at"), - help_text=_("Timestamp when the EAP was marked as pending PFA."), + verbose_name=_("project agreement signed at"), + help_text=_("Timestamp when the project agreement was signed."), ) # EAP submission deadline @@ -896,8 +902,14 @@ class CommonEAPFields(models.Model): blank=True, ) - people_targeted = models.IntegerField( - verbose_name=_("People Targeted."), + include_rcrc_climate_center = models.BooleanField( + verbose_name=_("Include RCRC Climate Center."), + default=False, + ) + + # TOTAL PEOPLE TARGETED + total_people_targeted = models.PositiveIntegerField( + verbose_name=_("Total People Targeted."), blank=True, null=True, ) @@ -908,9 +920,7 @@ class CommonEAPFields(models.Model): 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_title = models.CharField(verbose_name=_("national society contact title"), max_length=255) national_society_contact_email = models.CharField( verbose_name=_("national society contact email"), max_length=255, @@ -927,57 +937,6 @@ class CommonEAPFields(models.Model): 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( @@ -1047,20 +1006,6 @@ class CommonEAPFields(models.Model): verbose_name=_("IFRC regional head of DCC phone number"), max_length=100, null=True, blank=True ) - # Global Ops Manager - ifrc_global_ops_coordinator_name = models.CharField( - verbose_name=_("IFRC global ops coordinator name"), max_length=255, null=True, blank=True - ) - ifrc_global_ops_coordinator_email = models.CharField( - verbose_name=_("IFRC global ops coordinator email"), max_length=255, null=True, blank=True - ) - ifrc_global_ops_coordinator_title = models.CharField( - verbose_name=_("IFRC global ops coordinator title"), max_length=255, null=True, blank=True - ) - ifrc_global_ops_coordinator_phone_number = models.CharField( - verbose_name=_("IFRC global ops coordinator phone number"), max_length=100, null=True, blank=True - ) - # NOTE: Export files for EAPs, export_file = SecureFileField( verbose_name=_("EAP Export File"), @@ -1104,22 +1049,22 @@ class CommonEAPFields(models.Model): blank=True, ) - total_budget = models.IntegerField( + total_budget = models.PositiveIntegerField( verbose_name=_("Total Budget (CHF)"), null=True, blank=True, ) - readiness_budget = models.IntegerField( + readiness_budget = models.PositiveIntegerField( verbose_name=_("Readiness Budget (CHF)"), null=True, blank=True, ) - pre_positioning_budget = models.IntegerField( + pre_positioning_budget = models.PositiveIntegerField( verbose_name=_("Pre-positioning Budget (CHF)"), null=True, blank=True, ) - early_action_budget = models.IntegerField( + early_action_budget = models.PositiveIntegerField( verbose_name=_("Early Actions Budget (CHF)"), null=True, blank=True, @@ -1160,10 +1105,10 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): null=True, blank=True, ) - hazard_impact_images = models.ManyToManyField( + hazard_impact_files = models.ManyToManyField( EAPFile, - verbose_name=_("Hazard Impact Images"), - related_name="simplified_eap_hazard_impact_images", + verbose_name=_("Hazard Impact Files"), + related_name="simplified_eap_hazard_impact_files", blank=True, ) @@ -1173,10 +1118,10 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - risk_selected_protocols_images = models.ManyToManyField( + risk_selected_protocols_files = models.ManyToManyField( EAPFile, - verbose_name=_("Risk Selected Protocols Images"), - related_name="simplified_eap_risk_selected_protocols_images", + verbose_name=_("Risk Selected Protocols Files"), + related_name="simplified_eap_risk_selected_protocols_files", blank=True, ) @@ -1186,10 +1131,10 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): null=True, blank=True, ) - selected_early_actions_images = models.ManyToManyField( + selected_early_actions_files = models.ManyToManyField( EAPFile, - verbose_name=_("Selected Early Actions Images"), - related_name="simplified_eap_selected_early_actions_images", + verbose_name=_("Selected Early Actions Files"), + related_name="simplified_eap_selected_early_actions_files", blank=True, ) @@ -1238,17 +1183,17 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - # NOTE: operational_timeframe_unit and operational_time are atomic - # operational_timeframe is set default to Months - operational_timeframe_unit = models.IntegerField( + # NOTE: activation_timeframe_unit and activation_timeframe are atomic + # activation_timeframe_unit is set default to Months + activation_timeframe_unit = models.IntegerField( choices=TimeFrame.choices, default=TimeFrame.MONTHS, - verbose_name=_("Operational Timeframe Unit"), + verbose_name=_("Activation Timeframe Unit"), null=True, blank=True, ) - operational_timeframe = models.IntegerField( - verbose_name=_("Operational Time"), + activation_timeframe = models.IntegerField( + verbose_name=_("Activation Timeframe"), null=True, blank=True, ) @@ -1366,9 +1311,9 @@ def generate_snapshot(self): "admin2", "partners", "cover_image", - "hazard_impact_images", - "risk_selected_protocols_images", - "selected_early_actions_images", + "hazard_impact_files", + "risk_selected_protocols_files", + "selected_early_actions_files", }, ) @@ -1385,10 +1330,6 @@ def generate_snapshot(self): "readiness_budget", "pre_positioning_budget", "early_action_budget", - "ifrc_delegation_focal_point_name", - "ifrc_delegation_focal_point_email", - "ifrc_head_of_delegation_name", - "ifrc_head_of_delegation_email", "budget_file", "prioritized_hazard_and_impact", "risks_selected_protocols", @@ -1398,10 +1339,9 @@ def generate_snapshot(self): "assisted_through_operation", "seap_lead_timeframe_unit", "seap_lead_time", - "operational_timeframe_unit", - "operational_timeframe", + "activation_timeframe_unit", + "activation_timeframe", "trigger_threshold_justification", - "next_step_towards_full_eap", "early_action_capability", "rcrc_movement_involvement", ] @@ -1478,10 +1418,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - hazard_selection_images = models.ManyToManyField( + hazard_selection_files = models.ManyToManyField( EAPFile, - verbose_name=_("Hazard images"), - related_name="full_eap_hazard_selection_images", + verbose_name=_("Hazard Files"), + related_name="full_eap_hazard_selection_files", blank=True, ) @@ -1492,10 +1432,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - exposed_element_and_vulnerability_factor_images = models.ManyToManyField( + exposed_element_and_vulnerability_factor_files = models.ManyToManyField( EAPFile, - verbose_name=_("Exposed elements and vulnerability factors images"), - related_name="full_eap_vulnerability_factor_images", + verbose_name=_("Exposed elements and vulnerability factors files"), + related_name="full_eap_vulnerability_factor_files", blank=True, ) @@ -1512,10 +1452,10 @@ class FullEAP(EAPBaseModel, CommonEAPFields): related_name="full_eap_prioritized_impacts", ) - prioritized_impact_images = models.ManyToManyField( + prioritized_impact_files = models.ManyToManyField( EAPFile, - verbose_name=_("Prioritized impact images"), - related_name="full_eap_prioritized_impact_images", + verbose_name=_("Prioritized impact files"), + related_name="full_eap_prioritized_impact_files", blank=True, ) @@ -1578,9 +1518,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): related_name="forecast_table_file", ) - forecast_selection_images = models.ManyToManyField( + forecast_selection_files = models.ManyToManyField( EAPFile, - verbose_name=_("Forecast Selection Images"), + verbose_name=_("Forecast Selection Files"), related_name="+", blank=True, ) @@ -1591,9 +1531,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - definition_and_justification_impact_level_images = models.ManyToManyField( + definition_and_justification_impact_level_files = models.ManyToManyField( EAPFile, - verbose_name=_("Definition and Justification Impact Level Images"), + verbose_name=_("Definition and Justification Impact Level Files"), related_name="+", blank=True, ) @@ -1604,9 +1544,9 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - identification_of_the_intervention_area_images = models.ManyToManyField( + identification_of_the_intervention_area_files = models.ManyToManyField( EAPFile, - verbose_name=_("Intervention Area Images"), + verbose_name=_("Intervention Area Files"), related_name="+", blank=True, ) @@ -1639,11 +1579,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - early_action_selection_process_images = models.ManyToManyField( + early_action_selection_process_files = models.ManyToManyField( EAPFile, blank=True, - verbose_name=_("Early action selection process images"), - related_name="early_action_selection_process_images", + verbose_name=_("Early action selection process files"), + related_name="early_action_selection_process_files", ) theory_of_change_table_file = models.ForeignKey[EAPFile | None, EAPFile | None]( EAPFile, @@ -1712,11 +1652,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - early_action_implementation_images = models.ManyToManyField( + early_action_implementation_files = models.ManyToManyField( EAPFile, blank=True, - verbose_name=_("Early Action Implementation Images"), - related_name="early_action_implementation_images", + verbose_name=_("Early Action Implementation Files"), + related_name="early_action_implementation_files", ) trigger_activation_system = models.TextField( @@ -1726,11 +1666,11 @@ class FullEAP(EAPBaseModel, CommonEAPFields): blank=True, ) - trigger_activation_system_images = models.ManyToManyField( + trigger_activation_system_files = models.ManyToManyField( EAPFile, blank=True, - verbose_name=_("Trigger Activation System Images"), - related_name="trigger_activation_system_images", + verbose_name=_("Trigger Activation System Files"), + related_name="trigger_activation_system_files", ) selection_of_target_population = models.TextField( @@ -1918,19 +1858,19 @@ def generate_snapshot(self): "partners", "cover_image", # Files - "hazard_selection_images", + "hazard_selection_files", "theory_of_change_table_file", - "exposed_element_and_vulnerability_factor_images", - "prioritized_impact_images", + "exposed_element_and_vulnerability_factor_files", + "prioritized_impact_files", "risk_analysis_relevant_files", - "forecast_selection_images", - "definition_and_justification_impact_level_images", - "identification_of_the_intervention_area_images", + "forecast_selection_files", + "definition_and_justification_impact_level_files", + "identification_of_the_intervention_area_files", "trigger_model_relevant_files", - "early_action_selection_process_images", + "early_action_selection_process_files", "evidence_base_relevant_files", - "early_action_implementation_images", - "trigger_activation_system_images", + "early_action_implementation_files", + "trigger_activation_system_files", "activation_process_relevant_files", "meal_relevant_files", "capacity_relevant_files", @@ -1950,10 +1890,6 @@ def generate_snapshot(self): "readiness_budget", "pre_positioning_budget", "early_action_budget", - "ifrc_delegation_focal_point_name", - "ifrc_delegation_focal_point_email", - "ifrc_head_of_delegation_name", - "ifrc_head_of_delegation_email", "budget_file", "key_actors", "hazard_selection", diff --git a/eap/serializers.py b/eap/serializers.py index 9ebfa3e4a..af2ec6f30 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -49,17 +49,17 @@ send_feedback_email_for_resubmitted_eap, send_new_eap_registration_email, send_new_eap_submission_email, - send_pending_pfa_email, + send_project_agreement_signed_email, send_technical_validation_email, ) from eap.utils import ( has_country_permission, is_user_ifrc_admin, validate_file_extention, + validate_file_object, validate_for_under_review, ) from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin -from utils.file_check import validate_file_type ALLOWED_FILE_EXTENTIONS: list[str] = ["pdf", "docx", "pptx", "xlsx", "xlsm"] @@ -153,8 +153,7 @@ class Meta: ) def validate_file(self, file): - validate_file_type(file) - return file + return validate_file_object(file) # NOTE: Separate serializer for partial updating EAPFile instance @@ -178,8 +177,7 @@ def validate_id(self, id: int) -> int: return id def validate_file(self, file): - validate_file_type(file) - return file + return validate_file_object(file) # NOTE: Mini Serializers used for basic listing purpose @@ -257,6 +255,7 @@ class Meta: "eap_type_display", "disaster_type", "disaster_type_details", + "disaster_sub_type", "status", "status_display", "requirement_cost", @@ -334,8 +333,8 @@ def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.An EAPRegistration.Status.TECHNICALLY_VALIDATED.label, ) - validate_file_type(validated_data["validated_budget_file"]) validate_file_extention(validated_data["validated_budget_file"].name, ALLOWED_FILE_EXTENTIONS) + validate_file_object(validated_data["validated_budget_file"]) return validated_data @@ -457,7 +456,6 @@ class KeyActorSerializer( ): id = serializers.IntegerField(required=False) previous_id = serializers.IntegerField(read_only=True) - national_society_details = MiniCountrySerializer(source="national_society", read_only=True) class Meta: model = KeyActor @@ -492,7 +490,7 @@ class Meta: class CommonEAPFieldsSerializer(serializers.ModelSerializer): - MAX_NUMBER_OF_IMAGES = 5 + MAX_NUMBER_OF_FILES = 5 def get_fields(self): fields = super().get_fields() @@ -522,15 +520,14 @@ def validate_updated_checklist_file(self, file): return validate_file_extention(file.file.name, ALLOWED_FILE_EXTENTIONS) - validate_file_type(file.file) return file - def validate_images_field(self, field_name, images): - if images and len(images) > self.MAX_NUMBER_OF_IMAGES: + def validate_files_field(self, field_name, files): + if files and len(files) > self.MAX_NUMBER_OF_FILES: raise serializers.ValidationError( - {field_name: [f"Maximum {self.MAX_NUMBER_OF_IMAGES} images are allowed."]}, + {field_name: [f"Maximum {self.MAX_NUMBER_OF_FILES} files are allowed."]}, ) - return images + return files def update(self, instance, validated_data): modified_at = validated_data.pop("modified_at", None) @@ -559,24 +556,24 @@ class SimplifiedEAPSerializer( # FILES version = serializers.IntegerField(default=1, read_only=True) - hazard_impact_images = EAPFileUpdateSerializer(required=False, many=True) - selected_early_actions_images = EAPFileUpdateSerializer(required=False, many=True, allow_null=True) - risk_selected_protocols_images = EAPFileUpdateSerializer(required=False, many=True, allow_null=True) + hazard_impact_files = EAPFileUpdateSerializer(required=False, many=True) + selected_early_actions_files = EAPFileUpdateSerializer(required=False, many=True, allow_null=True) + risk_selected_protocols_files = EAPFileUpdateSerializer(required=False, many=True, allow_null=True) # TimeFrame seap_lead_timeframe_unit_display = serializers.CharField(source="get_seap_lead_timeframe_unit_display", read_only=True) - operational_timeframe_unit_display = serializers.CharField(source="get_operational_timeframe_unit_display", read_only=True) + activation_timeframe_unit_display = serializers.CharField(source="get_activation_timeframe_unit_display", read_only=True) - people_targeted = serializers.IntegerField(min_value=2000, required=False, allow_null=True) - # IMAGES + total_people_targeted = serializers.IntegerField(min_value=2000, required=False, allow_null=True) + # FILES - # 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. + # NOTE: When adding new file fields, include their names in FILE_FIELDS below + # if the file fields are to be validated against the MAX_NUMBER_OF_FILES limit. - IMAGE_FIELDS = [ - "hazard_impact_images", - "selected_early_actions_images", - "risk_selected_protocols_images", + FILE_FIELDS = [ + "hazard_impact_files", + "selected_early_actions_files", + "risk_selected_protocols_files", ] class Meta: @@ -623,26 +620,26 @@ def _validate_timeframe(self, data: dict[str, typing.Any]) -> None: } ) - # --- Operational TimeFrame --- - op_unit = data.get("operational_timeframe_unit") - op_value = data.get("operational_timeframe") + # --- Activation TimeFrame --- + activation_unit = data.get("activation_timeframe_unit") + activation_value = data.get("activation_timeframe") - if op_value is not None and op_unit is None: + if activation_value is not None and activation_unit is None: raise serializers.ValidationError( { - "operational_timeframe_unit": gettext("operational timeframe and unit must both be provided."), + "activation_timeframe_unit": gettext("activation timeframe and unit must both be provided."), } ) - if op_unit is not None and op_value is not None: - if op_unit != TimeFrame.MONTHS: + if activation_unit is not None and activation_value is not None: + if activation_unit != TimeFrame.MONTHS: raise serializers.ValidationError( - {"operational_timeframe_unit": gettext("operational timeframe unit must be Months.")} + {"activation_timeframe_unit": gettext("activation timeframe unit must be Months.")} ) - if op_value not in MonthsTimeFrameChoices: + if activation_value not in MonthsTimeFrameChoices: raise serializers.ValidationError( - {"operational_timeframe": gettext("operational timeframe value is not valid for Months unit.")} + {"activation_timeframe": gettext("activation timeframe value is not valid for Months unit.")} ) def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: @@ -676,10 +673,10 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: # Validate timeframe fields self._validate_timeframe(data) - # Validate all image fields in one place - for field in self.IMAGE_FIELDS: + # Validate all file fields in one place + for field in self.FILE_FIELDS: if field in data: - self.validate_images_field(field, data[field]) + self.validate_files_field(field, data[field]) return data def create(self, validated_data: dict[str, typing.Any]): @@ -704,7 +701,7 @@ class FullEAPSerializer( early_actions = EAPActionSerializer(many=True, required=True) prioritized_impacts = ImpactSerializer(many=True, required=True) - people_targeted = serializers.IntegerField(min_value=10000, required=False, allow_null=True) + total_people_targeted = serializers.IntegerField(min_value=10000, required=False, allow_null=True) # SOURCE OF INFORMATIONS risk_analysis_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) @@ -715,48 +712,48 @@ class FullEAPSerializer( meal_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) ns_capacity_source_of_information = EAPSourceInformationSerializer(many=True, required=False, allow_null=True) - # IMAGES - hazard_selection_images = EAPFileUpdateSerializer( + # M2M FILES + hazard_selection_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - exposed_element_and_vulnerability_factor_images = EAPFileUpdateSerializer( + exposed_element_and_vulnerability_factor_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - prioritized_impact_images = EAPFileUpdateSerializer( + prioritized_impact_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - forecast_selection_images = EAPFileUpdateSerializer( + forecast_selection_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - definition_and_justification_impact_level_images = EAPFileUpdateSerializer( + definition_and_justification_impact_level_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - identification_of_the_intervention_area_images = EAPFileUpdateSerializer( + identification_of_the_intervention_area_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - early_action_selection_process_images = EAPFileUpdateSerializer( + early_action_selection_process_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - early_action_implementation_images = EAPFileUpdateSerializer( + early_action_implementation_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, ) - trigger_activation_system_images = EAPFileUpdateSerializer( + trigger_activation_system_files = EAPFileUpdateSerializer( many=True, required=False, allow_null=True, @@ -780,19 +777,19 @@ 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", + # NOTE: When adding new file fields, include their names in FILE_FIELDS below + # if the file fields are to be validated against the MAX_NUMBER_OF_FILES limit. + + FILE_FIELDS = [ + "hazard_selection_files", + "exposed_element_and_vulnerability_factor_files", + "prioritized_impact_files", + "forecast_selection_files", + "definition_and_justification_impact_level_files", + "identification_of_the_intervention_area_files", + "early_action_selection_process_files", + "early_action_implementation_files", + "trigger_activation_system_files", ] class Meta: @@ -823,8 +820,16 @@ def _validate_timeframe(self, data: dict[str, typing.Any]) -> None: } ) - if lead_unit is not None and lead_time_value is not None and lead_unit != TimeFrame.DAYS: - raise serializers.ValidationError({"lead_timeframe_unit": gettext("lead timeframe unit must be Days for Full EAP.")}) + if lead_unit is not None and lead_time_value is not None: + allowed_units = [ + TimeFrame.MONTHS, + TimeFrame.DAYS, + TimeFrame.HOURS, + ] + if lead_unit not in allowed_units: + raise serializers.ValidationError( + {"lead_timeframe_unit": gettext("lead timeframe unit must be one of the following: Months, Days, or Hours.")} + ) 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 @@ -857,10 +862,10 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: # Validate timeframe fields self._validate_timeframe(data) - # Validate all image fields in one place - for field in self.IMAGE_FIELDS: + # Validate all file fields in one place + for field in self.FILE_FIELDS: if field in data: - self.validate_images_field(field, data[field]) + self.validate_files_field(field, data[field]) return data def create(self, validated_data: dict[str, typing.Any]): @@ -887,8 +892,8 @@ def create(self, validated_data: dict[str, typing.Any]): (EAPRegistration.Status.NS_ADDRESSING_COMMENTS, EAPRegistration.Status.UNDER_REVIEW), (EAPRegistration.Status.UNDER_REVIEW, EAPRegistration.Status.TECHNICALLY_VALIDATED), (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), - (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.PENDING_PFA), - (EAPRegistration.Status.PENDING_PFA, EAPRegistration.Status.APPROVED), + (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.APPROVED), + (EAPRegistration.Status.APPROVED, EAPRegistration.Status.PROJECT_AGREEMENT_SIGNED), ] ) @@ -1054,7 +1059,7 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t elif (current_status, new_status) == ( EAPRegistration.Status.TECHNICALLY_VALIDATED, - EAPRegistration.Status.PENDING_PFA, + EAPRegistration.Status.APPROVED, ): if not is_user_ifrc_admin(user): raise PermissionDenied( @@ -1068,22 +1073,22 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ) # Update timestamp - self.instance.pending_pfa_at = timezone.now() + self.instance.approved_at = timezone.now() self.instance.save( update_fields=[ - "pending_pfa_at", + "approved_at", ] ) elif (current_status, new_status) == ( - EAPRegistration.Status.PENDING_PFA, EAPRegistration.Status.APPROVED, + EAPRegistration.Status.PROJECT_AGREEMENT_SIGNED, ): # Update timestamp - self.instance.approved_at = timezone.now() + self.instance.project_agreement_signed_at = timezone.now() self.instance.save( update_fields=[ - "approved_at", + "project_agreement_signed_at", ] ) @@ -1098,7 +1103,7 @@ def validate_review_checklist_file(self, file): return validate_file_extention(file.name, ALLOWED_FILE_EXTENTIONS) - validate_file_type(file) + validate_file_object(file) return file @@ -1206,9 +1211,9 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any elif (old_status, new_status) == ( EAPRegistration.Status.TECHNICALLY_VALIDATED, - EAPRegistration.Status.PENDING_PFA, + EAPRegistration.Status.APPROVED, ): - # NOTE: Generating diff pdf and summary pdf (for full eap) and sending email to PFA after technical validation. + # NOTE: Generating diff pdf and summary pdf (for full eap) and sending email to IFRC after approval. is_full_eap = instance.get_eap_type_enum == EAPType.FULL_EAP version = instance.latest_simplified_eap.version if not is_full_eap else instance.latest_full_eap.version @@ -1222,15 +1227,15 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any transaction.on_commit( lambda: chain( group(tasks), - send_pending_pfa_email.si(eap_registration_id), + send_approved_email.si(eap_registration_id), ).apply_async() ) elif (old_status, new_status) == ( - EAPRegistration.Status.PENDING_PFA, EAPRegistration.Status.APPROVED, + EAPRegistration.Status.PROJECT_AGREEMENT_SIGNED, ): - transaction.on_commit(lambda: send_approved_email.delay(eap_registration_id)) + transaction.on_commit(lambda: send_project_agreement_signed_email.delay(eap_registration_id)) return updated_instance diff --git a/eap/tasks.py b/eap/tasks.py index 80763f0af..83582e94f 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -257,8 +257,6 @@ def send_feedback_email(eap_registration_id: int): else: latest_eap = instance.latest_full_eap - ifrc_delegation_focal_point_email = latest_eap.ifrc_delegation_focal_point_email - partner_ns_emails = list(latest_eap.partner_contacts.values_list("email", flat=True)) regional_coordinator_emails: list[str] = get_regional_coordinator_emails(instance.country.region) @@ -270,7 +268,6 @@ def send_feedback_email(eap_registration_id: int): cc_recipients = list( set( [ - ifrc_delegation_focal_point_email, *get_emails_by_type(EmailRecipient.EmailType.DREF_ANTICIPATORY), *get_emails_by_type(EmailRecipient.EmailType.DREF_AA_GLOBAL_TEAM), *regional_coordinator_emails, @@ -434,7 +431,7 @@ def send_technical_validation_email(eap_registration_id: int): @shared_task -def send_pending_pfa_email(eap_registration_id: int): +def send_approved_email(eap_registration_id: int): instance = EAPRegistration.objects.filter(id=eap_registration_id).first() if not instance: @@ -462,9 +459,9 @@ def send_pending_pfa_email(eap_registration_id: int): ) ) email_context = get_eap_email_context(instance) - email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED PENDING PFA] {instance.country} {instance.disaster_type}" - email_body = render_to_string("email/eap/pending_pfa.html", email_context) - email_type = "Approved Pending PFA EAP" + email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED] {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/approved.html", email_context) + email_type = "Approved EAP" send_notification( subject=email_subject, recipients=recipient, @@ -476,7 +473,7 @@ def send_pending_pfa_email(eap_registration_id: int): @shared_task -def send_approved_email(eap_registration_id: int): +def send_project_agreement_signed_email(eap_registration_id: int): instance = EAPRegistration.objects.filter(id=eap_registration_id).first() if not instance: @@ -506,9 +503,11 @@ def send_approved_email(eap_registration_id: int): ) ) email_context = get_eap_email_context(instance) - email_subject = f"[DREF {instance.get_eap_type_display()} APPROVED] {instance.country} {instance.disaster_type}" - email_body = render_to_string("email/eap/approved.html", email_context) - email_type = "Approved EAP" + email_subject = ( + f"[DREF {instance.get_eap_type_display()} PROJECT AGREEMENT SIGNED] {instance.country} {instance.disaster_type}" + ) + email_body = render_to_string("email/eap/project_agreement_signed.html", email_context) + email_type = "Project Agreement Signed EAP" send_notification( subject=email_subject, recipients=recipient, @@ -553,7 +552,7 @@ def send_deadline_reminder_email(eap_registration_id: int): email_context = get_eap_email_context(instance) email_subject = f"[DREF {instance.get_eap_type_display()} SUBMISSION REMINDER] {instance.country} {instance.disaster_type}" email_body = render_to_string("email/eap/reminder.html", email_context) - email_type = "Approved EAP" + email_type = "EAP Submission Reminder" send_notification( subject=email_subject, recipients=recipient, diff --git a/eap/test_views.py b/eap/test_views.py index 411e1cf32..100d123bd 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -119,6 +119,7 @@ def test_list_eap_registration(self): country=self.country, national_society=self.national_society, disaster_type=self.disaster_type, + disaster_sub_type="disaster sub type", created_by=self.country_admin, modified_by=self.country_admin, ) @@ -136,9 +137,11 @@ def test_create_eap_registration(self, send_new_eap_registration_email): "country": self.country.id, "national_society": self.national_society.id, "disaster_type": self.disaster_type.id, + "disaster_sub_type": "disaster sub type", "expected_submission_time": "2024-12-31", "partners": [self.partner1.id, self.partner2.id], "national_society_contact_name": "National society contact name", + "national_society_contact_title": "National society contact title", "national_society_contact_email": "test@example.com", } @@ -158,12 +161,14 @@ def test_create_eap_registration(self, send_new_eap_registration_email): response.data["status"], response.data["country"], response.data["disaster_type_details"]["id"], + response.data["disaster_sub_type"], }, { EAPType.FULL_EAP, EAPStatus.UNDER_DEVELOPMENT, self.country.id, self.disaster_type.id, + data["disaster_sub_type"], }, ) self.assertTrue(send_new_eap_registration_email) @@ -190,6 +195,7 @@ def test_update_eap_registration(self): eap_type=EAPType.SIMPLIFIED_EAP, national_society=self.national_society, disaster_type=self.disaster_type, + disaster_sub_type="disaster sub type", partners=[self.partner1.id], created_by=self.country_admin, modified_by=self.country_admin, @@ -204,6 +210,7 @@ def test_update_eap_registration(self): "country": country2.id, "national_society": self.national_society.id, "disaster_type": self.disaster_type.id, + "disaster_sub_type": "updated disaster sub type", "expected_submission_time": "2025-01-15", "partners": [self.partner2.id, partner3.id], } @@ -255,7 +262,7 @@ def test_active_eaps(self): partners=[self.partner1.id], created_by=self.country_admin, modified_by=self.country_admin, - status=EAPStatus.APPROVED, + status=EAPStatus.PROJECT_AGREEMENT_SIGNED, eap_type=EAPType.FULL_EAP, ) eap_registration_2 = EAPRegistrationFactory.create( @@ -265,7 +272,7 @@ def test_active_eaps(self): partners=[self.partner2.id], created_by=self.country_admin, modified_by=self.country_admin, - status=EAPStatus.APPROVED, + status=EAPStatus.PROJECT_AGREEMENT_SIGNED, eap_type=EAPType.SIMPLIFIED_EAP, ) EAPRegistrationFactory.create( @@ -550,11 +557,8 @@ def test_create_simplified_eap(self): data = { "eap_registration": eap_registration.id, "national_society_contact_name": "National society contact name", + "national_society_contact_title": "National society contact title", "national_society_contact_email": "test@example.com", - "ifrc_delegation_focal_point_name": "IFRC delegation focal point name", - "ifrc_delegation_focal_point_email": "test_ifrc@example.com", - "ifrc_head_of_delegation_name": "IFRC head of delegation name", - "ifrc_head_of_delegation_email": "ifrc_head@example.com", "partner_contacts": [ { "name": "Partner 1 Contact", @@ -577,7 +581,7 @@ 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": [ + "hazard_impact_files": [ { "id": image_1.id, "caption": "Image 1 caption", @@ -587,7 +591,7 @@ def test_create_simplified_eap(self): "caption": "Image 2 caption", }, ], - "selected_early_actions_images": [ + "selected_early_actions_files": [ { "id": image_1.id, "caption": "Image 1 caption for early actions", @@ -601,12 +605,12 @@ def test_create_simplified_eap(self): "seap_timeframe": 3, "seap_lead_timeframe_unit": TimeFrame.MONTHS, "seap_lead_time": 6, - "operational_timeframe_unit": TimeFrame.MONTHS, - "operational_timeframe": 12, + "activation_timeframe_unit": TimeFrame.MONTHS, + "activation_timeframe": 12, "readiness_budget": 3000, "pre_positioning_budget": 4000, "early_action_budget": 3000, - "people_targeted": 5000, + "total_people_targeted": 5000, "next_step_towards_full_eap": "Plan to expand.", "planned_operations": [ { @@ -627,6 +631,8 @@ def test_create_simplified_eap(self): { "activity": "early action activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [ YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.TWO_YEARS, @@ -637,6 +643,8 @@ def test_create_simplified_eap(self): { "activity": "prepositioning activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [ YearsTimeFrameChoices.TWO_YEARS, YearsTimeFrameChoices.THREE_YEARS, @@ -670,6 +678,8 @@ def test_create_simplified_eap(self): { "activity": "early action activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [ YearsTimeFrameChoices.TWO_YEARS, YearsTimeFrameChoices.THREE_YEARS, @@ -680,6 +690,8 @@ def test_create_simplified_eap(self): { "activity": "prepositioning activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [YearsTimeFrameChoices.THREE_YEARS], } ], @@ -854,8 +866,8 @@ def test_update_simplified_eap(self): modified_by=self.country_admin, seap_lead_timeframe_unit=TimeFrame.MONTHS, seap_lead_time=12, - operational_timeframe=12, - operational_timeframe_unit=TimeFrame.MONTHS, + activation_timeframe=12, + activation_timeframe_unit=TimeFrame.MONTHS, budget_file=EAPFileFactory._create_file( created_by=self.country_admin, modified_by=self.country_admin, @@ -890,6 +902,8 @@ def test_update_simplified_eap(self): "id": enabling_approach_prepositioning_operation_activity_1.id, "activity": "Updated Enabling Approach Prepositioning Activity 1", "timeframe": TimeFrame.MONTHS, + "activation_one": True, + "activation_two": False, "time_value": [MonthsTimeFrameChoices.FOUR_MONTHS], } ], @@ -898,6 +912,8 @@ def test_update_simplified_eap(self): "id": enabling_approach_early_action_operation_activity_1.id, "activity": "Updated Enabling Approach Early Action Activity 1", "timeframe": TimeFrame.DAYS, + "activation_one": True, + "activation_two": False, "time_value": [DaysTimeFrameChoices.TEN_DAYS], } ], @@ -920,6 +936,8 @@ def test_update_simplified_eap(self): { "activity": "New Enabling Approach Prepositioning Activity", "timeframe": TimeFrame.MONTHS, + "activation_one": True, + "activation_two": False, "time_value": [ MonthsTimeFrameChoices.SIX_MONTHS, MonthsTimeFrameChoices.NINE_MONTHS, @@ -930,6 +948,8 @@ def test_update_simplified_eap(self): { "activity": "New Enabling Approach Early Action Activity", "timeframe": TimeFrame.DAYS, + "activation_one": True, + "activation_two": False, "time_value": [ DaysTimeFrameChoices.EIGHT_DAYS, DaysTimeFrameChoices.SIXTEEN_DAYS, @@ -970,6 +990,8 @@ def test_update_simplified_eap(self): "id": planned_operation_prepositioning_operation_activity_1.id, "activity": "Updated Planned Operation Prepositioning Activity 1", "timeframe": TimeFrame.MONTHS, + "activation_one": True, + "activation_two": False, "time_value": [ MonthsTimeFrameChoices.THREE_MONTHS, MonthsTimeFrameChoices.SIX_MONTHS, @@ -981,6 +1003,8 @@ def test_update_simplified_eap(self): "id": planned_operation_early_action_operation_activity_1.id, "activity": "Updated Planned Operation Early Action Activity 1", "timeframe": TimeFrame.DAYS, + "activation_one": True, + "activation_two": False, "time_value": [ DaysTimeFrameChoices.EIGHT_DAYS, DaysTimeFrameChoices.SIXTEEN_DAYS, @@ -1007,6 +1031,8 @@ def test_update_simplified_eap(self): { "activity": "New Planned Operation Prepositioning Activity", "timeframe": TimeFrame.MONTHS, + "activation_one": True, + "activation_two": False, "time_value": [ MonthsTimeFrameChoices.TWO_MONTHS, MonthsTimeFrameChoices.FIVE_MONTHS, @@ -1017,6 +1043,8 @@ def test_update_simplified_eap(self): { "activity": "New Planned Operation Early Action Activity", "timeframe": TimeFrame.DAYS, + "activation_one": True, + "activation_two": False, "time_value": [ MonthsTimeFrameChoices.FIVE_MONTHS, MonthsTimeFrameChoices.TWELVE_MONTHS, @@ -1076,16 +1104,24 @@ def test_update_simplified_eap(self): response.data["enabling_approaches"][0]["prepositioning_activities"][0]["id"], response.data["enabling_approaches"][0]["prepositioning_activities"][0]["activity"], response.data["enabling_approaches"][0]["prepositioning_activities"][0]["timeframe"], + response.data["enabling_approaches"][0]["prepositioning_activities"][0]["activation_one"], + response.data["enabling_approaches"][0]["prepositioning_activities"][0]["activation_two"], # NEW PREPOSITIONING ACTIVITY response.data["enabling_approaches"][1]["prepositioning_activities"][0]["activity"], response.data["enabling_approaches"][1]["prepositioning_activities"][0]["timeframe"], + response.data["enabling_approaches"][1]["prepositioning_activities"][0]["activation_one"], + response.data["enabling_approaches"][1]["prepositioning_activities"][0]["activation_two"], # EARLY ACTION ACTIVITY response.data["enabling_approaches"][0]["early_action_activities"][0]["id"], response.data["enabling_approaches"][0]["early_action_activities"][0]["activity"], response.data["enabling_approaches"][0]["early_action_activities"][0]["timeframe"], + response.data["enabling_approaches"][0]["early_action_activities"][0]["activation_one"], + response.data["enabling_approaches"][0]["early_action_activities"][0]["activation_two"], # NEW EARLY ACTION ACTIVITY response.data["enabling_approaches"][1]["early_action_activities"][0]["activity"], response.data["enabling_approaches"][1]["early_action_activities"][0]["timeframe"], + response.data["enabling_approaches"][1]["early_action_activities"][0]["activation_one"], + response.data["enabling_approaches"][1]["early_action_activities"][0]["activation_two"], }, { # READINESS ACTIVITY @@ -1099,16 +1135,24 @@ def test_update_simplified_eap(self): enabling_approach_prepositioning_operation_activity_1.id, data["enabling_approaches"][0]["prepositioning_activities"][0]["activity"], data["enabling_approaches"][0]["prepositioning_activities"][0]["timeframe"], + data["enabling_approaches"][0]["prepositioning_activities"][0]["activation_one"], + data["enabling_approaches"][0]["prepositioning_activities"][0]["activation_two"], # NEW PREPOSITIONING Activity data["enabling_approaches"][1]["prepositioning_activities"][0]["activity"], data["enabling_approaches"][1]["prepositioning_activities"][0]["timeframe"], + data["enabling_approaches"][1]["prepositioning_activities"][0]["activation_one"], + data["enabling_approaches"][1]["prepositioning_activities"][0]["activation_two"], # EARLY ACTION ACTIVITY enabling_approach_early_action_operation_activity_1.id, data["enabling_approaches"][0]["early_action_activities"][0]["activity"], data["enabling_approaches"][0]["early_action_activities"][0]["timeframe"], + data["enabling_approaches"][0]["early_action_activities"][0]["activation_one"], + data["enabling_approaches"][0]["early_action_activities"][0]["activation_two"], # NEW EARLY ACTION ACTIVITY data["enabling_approaches"][1]["early_action_activities"][0]["activity"], data["enabling_approaches"][1]["early_action_activities"][0]["timeframe"], + data["enabling_approaches"][1]["early_action_activities"][0]["activation_one"], + data["enabling_approaches"][1]["early_action_activities"][0]["activation_two"], }, ) @@ -1150,16 +1194,24 @@ def test_update_simplified_eap(self): 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"], + response.data["planned_operations"][0]["prepositioning_activities"][0]["activation_one"], + response.data["planned_operations"][0]["prepositioning_activities"][0]["activation_two"], # NEW PREPOSITIONING ACTIVITY response.data["planned_operations"][1]["prepositioning_activities"][0]["activity"], response.data["planned_operations"][1]["prepositioning_activities"][0]["timeframe"], + response.data["planned_operations"][1]["prepositioning_activities"][0]["activation_one"], + response.data["planned_operations"][1]["prepositioning_activities"][0]["activation_two"], # 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"], + response.data["planned_operations"][0]["early_action_activities"][0]["activation_one"], + response.data["planned_operations"][0]["early_action_activities"][0]["activation_two"], # NEW EARLY ACTION ACTIVITY response.data["planned_operations"][1]["early_action_activities"][0]["activity"], response.data["planned_operations"][1]["early_action_activities"][0]["timeframe"], + response.data["planned_operations"][1]["early_action_activities"][0]["activation_one"], + response.data["planned_operations"][1]["early_action_activities"][0]["activation_two"], }, { # READINESS ACTIVITY @@ -1173,16 +1225,24 @@ def test_update_simplified_eap(self): planned_operation_prepositioning_operation_activity_1.id, data["planned_operations"][0]["prepositioning_activities"][0]["activity"], data["planned_operations"][0]["prepositioning_activities"][0]["timeframe"], + data["planned_operations"][0]["prepositioning_activities"][0]["activation_one"], + data["planned_operations"][0]["prepositioning_activities"][0]["activation_two"], # NEW PREPOSITIONING ACTIVITY data["planned_operations"][1]["prepositioning_activities"][0]["activity"], data["planned_operations"][1]["prepositioning_activities"][0]["timeframe"], + data["planned_operations"][1]["prepositioning_activities"][0]["activation_one"], + data["planned_operations"][1]["prepositioning_activities"][0]["activation_two"], # 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"], + data["planned_operations"][0]["early_action_activities"][0]["activation_one"], + data["planned_operations"][0]["early_action_activities"][0]["activation_two"], # NEW EARLY ACTION Activity data["planned_operations"][1]["early_action_activities"][0]["activity"], data["planned_operations"][1]["early_action_activities"][0]["timeframe"], + data["planned_operations"][1]["early_action_activities"][0]["activation_one"], + data["planned_operations"][1]["early_action_activities"][0]["activation_two"], }, ) @@ -1721,10 +1781,10 @@ def test_status_transition(self): self.eap_registration.technically_validated_at, ) - # NOTE: Transition to APPROVED - # TECHNICALLY_VALIDATED -> PENDING_PFA + # NOTE: Transition to PROJECT_AGREEMENT_SIGNED + # TECHNICALLY_VALIDATED -> APPROVED data = { - "status": EAPStatus.PENDING_PFA, + "status": EAPStatus.APPROVED, } # LOGIN as country admin user @@ -1750,25 +1810,25 @@ def test_status_transition(self): # LOGIN as IFRC admin user # SUCCESS: As only ifrc admins or superuser can - self.assertIsNone(self.eap_registration.pending_pfa_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, response.data) - self.assertEqual(response.data["status"], EAPStatus.PENDING_PFA) + 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.pending_pfa_at) + self.assertIsNotNone(self.eap_registration.approved_at) - # NOTE: Check as if user cannot update after PENDING_PFA_AT - # FAILS As simplified EAP is in PENDING_PFA, cannot updated + # NOTE: Check as if user cannot update after APPROVED_AT + # FAILS As simplified EAP is in APPROVED, cannot updated url = f"/api/v2/simplified-eap/{simplified_eap.id}/" response = self.client.patch(url, update_data, format="json") self.assertEqual(response.status_code, 400, response.data) - # NOTE: Transition to APPROVED - # PENDING_PFA -> APPROVED + # NOTE: Transition to PROJECT_AGREEMENT_SIGNED + # APPROVED -> PROJECT_AGREEMENT_SIGNED data = { - "status": EAPStatus.APPROVED, + "status": EAPStatus.PROJECT_AGREEMENT_SIGNED, } # LOGIN as country admin user @@ -1779,17 +1839,17 @@ 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.project_agreement_signed_at) self.authenticate(self.ifrc_admin_user) response = self.client.post(self.url, data, format="json") self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["status"], EAPStatus.APPROVED) - # Check is the pfa_signed timeline is added + self.assertEqual(response.data["status"], EAPStatus.PROJECT_AGREEMENT_SIGNED) + # Check is the project_agreement_signed_at timeline is added self.eap_registration.refresh_from_db() - self.assertIsNotNone(self.eap_registration.approved_at) + self.assertIsNotNone(self.eap_registration.project_agreement_signed_at) - # Check as if NS user cannot update after APPROVED - # FAILS As simplified EAP is in APPROVED, cannot update + # Check as if NS user cannot update after PROJECT_AGREEMENT_SIGNED + # FAILS As simplified EAP is in PROJECT_AGREEMENT_SIGNED, cannot update self.authenticate(self.country_admin) url = f"/api/v2/simplified-eap/{simplified_eap.id}/" response = self.client.patch(url, update_data, format="json") @@ -1801,8 +1861,10 @@ def test_status_transition(self): @mock.patch("eap.serializers.send_technical_validation_email") @mock.patch("eap.serializers.send_feedback_email_for_resubmitted_eap") @mock.patch("eap.serializers.send_approved_email") + @mock.patch("eap.serializers.send_project_agreement_signed_email") def test_status_transitions_trigger_email( self, + send_project_agreement_signed_email, send_approved_email, send_feedback_email_for_resubmitted_eap, send_technical_validation_email, @@ -2109,9 +2171,9 @@ def test_status_transitions_trigger_email( send_technical_validation_email.delay.assert_called_once_with(eap_registration.id) send_technical_validation_email.delay.reset_mock() - # Transition TECHNICALLY_VALIDATED -> PENDING_PFA + # Transition TECHNICALLY_VALIDATED -> APPROVED # Upload validated budget file - data = {"status": EAPStatus.PENDING_PFA} + data = {"status": EAPStatus.APPROVED} upload_url = f"/api/v2/eap-registration/{eap_registration.id}/upload-validated-budget-file/" with tempfile.NamedTemporaryFile(suffix=".xlsx") as tmp_file: tmp_file.write(b"Test content") @@ -2121,14 +2183,14 @@ def test_status_transitions_trigger_email( response = self.client.post(upload_url, file_data, format="multipart") self.assert_200(response) - # Now change status → PENDING_PFA + # Now change status -> APPROVED status_url = f"/api/v2/eap-registration/{eap_registration.id}/status/" - data = {"status": EAPStatus.PENDING_PFA} + data = {"status": EAPStatus.APPROVED} with self.capture_on_commit_callbacks(execute=True): response = self.client.post(status_url, data, format="json") self.assert_200(response) - self.assertEqual(response.data["status"], EAPStatus.PENDING_PFA) + self.assertEqual(response.data["status"], EAPStatus.APPROVED) eap_registration.refresh_from_db() self.assertTrue(mock_chain.called) @@ -2141,14 +2203,14 @@ def test_status_transitions_trigger_email( mock_chain.reset_mock() mock_group.reset_mock() - # Transition PENDING_PFA -> APPROVED - data = {"status": EAPStatus.APPROVED} + # Transition APPROVED -> PROJECT_AGREEMENT_SIGNED + data = {"status": EAPStatus.PROJECT_AGREEMENT_SIGNED} with self.capture_on_commit_callbacks(execute=True): response = self.client.post(url, data, format="json") self.assert_200(response) - self.assertEqual(response.data["status"], EAPStatus.APPROVED) + self.assertEqual(response.data["status"], EAPStatus.PROJECT_AGREEMENT_SIGNED) eap_registration.refresh_from_db() - send_approved_email.delay.assert_called_once_with(eap_registration.id) + send_project_agreement_signed_email.delay.assert_called_once_with(eap_registration.id) class EAPPDFExportTestCase(APITestCase): @@ -2421,11 +2483,8 @@ def test_create_full_eap(self): data = { "eap_registration": eap_registration.id, "national_society_contact_name": "National society contact name", + "national_society_contact_title": "National society contact title", "national_society_contact_email": "test@example.com", - "ifrc_delegation_focal_point_name": "IFRC delegation focal point name", - "ifrc_delegation_focal_point_email": "test_ifrc@example.com", - "ifrc_head_of_delegation_name": "IFRC head of delegation name", - "ifrc_head_of_delegation_email": "ifrc_head@example.com", "early_actions": [ { "action": "Early action 1", @@ -2457,7 +2516,7 @@ def test_create_full_eap(self): ], "budget_file": budget_file_instance.id, "forecast_table_file": forecast_table_file.id, - "hazard_selection_images": [ + "hazard_selection_files": [ { "id": image_1.id, "caption": "Image 1 caption", @@ -2467,7 +2526,7 @@ def test_create_full_eap(self): "caption": "Image 2 caption", }, ], - "exposed_element_and_vulnerability_factor_images": [ + "exposed_element_and_vulnerability_factor_files": [ { "id": image_1.id, "caption": "Image 1 caption", @@ -2477,7 +2536,7 @@ def test_create_full_eap(self): "caption": "Image 2 caption", }, ], - "prioritized_impact_images": [ + "prioritized_impact_files": [ { "id": image_1.id, }, @@ -2485,7 +2544,7 @@ def test_create_full_eap(self): "id": image_2.id, }, ], - "forecast_selection_images": [ + "forecast_selection_files": [ { "id": image_1.id, }, @@ -2502,14 +2561,14 @@ def test_create_full_eap(self): "readiness_budget": 3000, "pre_positioning_budget": 4000, "early_action_budget": 3000, - "people_targeted": 10000, + "total_people_targeted": 10000, "key_actors": [ { - "national_society": self.national_society.id, + "partner": self.national_society.name, "description": "Key actor 1 description", }, { - "national_society": self.country.id, + "partner": self.country.name, "description": "Key actor 1 description", }, ], @@ -2562,6 +2621,8 @@ def test_create_full_eap(self): { "activity": "early action activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [ YearsTimeFrameChoices.ONE_YEAR, YearsTimeFrameChoices.TWO_YEARS, @@ -2572,6 +2633,8 @@ def test_create_full_eap(self): { "activity": "prepositioning activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [ YearsTimeFrameChoices.TWO_YEARS, YearsTimeFrameChoices.THREE_YEARS, @@ -2605,6 +2668,8 @@ def test_create_full_eap(self): { "activity": "early action activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [ YearsTimeFrameChoices.TWO_YEARS, YearsTimeFrameChoices.THREE_YEARS, @@ -2615,6 +2680,8 @@ def test_create_full_eap(self): { "activity": "prepositioning activity", "timeframe": TimeFrame.YEARS, + "activation_one": True, + "activation_two": False, "time_value": [YearsTimeFrameChoices.THREE_YEARS], } ], @@ -2688,11 +2755,11 @@ def test_update_full_eap(self): "total_budget": 20000, "key_actors": [ { - "national_society": self.national_society.id, + "partner": self.national_society.name, "description": "Key actor 1 description", }, { - "national_society": self.country.id, + "partner": self.country.name, "description": "Key actor 1 description", }, ], @@ -2747,12 +2814,12 @@ def test_snapshot_full_eap(self): modified_by=self.user, ) key_actor_1 = KeyActorFactory.create( - national_society=self.national_society, + partner=self.national_society.name, description="Key actor 1 description", ) key_actor_2 = KeyActorFactory.create( - national_society=self.country, + partner=self.country.name, description="Key actor 1 description", ) @@ -2790,7 +2857,7 @@ def test_snapshot_full_eap(self): original.key_actors.add(key_actor_1, key_actor_2) original.enabling_approaches.add(enabling_approach) original.planned_operations.add(planned_operation) - original.hazard_selection_images.add(hazard_selection_image_1, hazard_selection_image_2) + original.hazard_selection_files.add(hazard_selection_image_1, hazard_selection_image_2) # Generate snapshot snapshot = original.generate_snapshot() @@ -2852,21 +2919,21 @@ def test_snapshot_full_eap(self): ) # M2M hazard selection images copied - orig_hazard_images = list(original.hazard_selection_images.all()) - snapshot_hazard_images = list(snapshot.hazard_selection_images.all()) + orig_hazard_images = list(original.hazard_selection_files.all()) + snapshot_hazard_images = list(snapshot.hazard_selection_files.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 + # M2M Actors deeply cloned 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, + orig_actors[0].partner, + snapshot_actors[0].partner, ) self.assertEqual( orig_actors[0].description, diff --git a/eap/utils.py b/eap/utils.py index 176d17c77..2d7da8c22 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -11,6 +11,7 @@ from api.models import Region from eap.models import EAPType, EmailRecipient +from utils.file_check import validate_file_type def get_emails_by_type(email_type: EmailRecipient.EmailType) -> list[str]: @@ -124,7 +125,7 @@ def get_eap_email_context(instance): email_context.update( { "latest_eap_id": latest_eap_data.id, - "people_targeted": latest_eap_data.people_targeted, + "people_targeted": latest_eap_data.total_people_targeted, "total_budget": latest_eap_data.total_budget, "latest_version": latest_eap_data.version, "export_file": latest_eap_data.export_file.url if latest_eap_data.export_file else None, @@ -178,6 +179,14 @@ def validate_file_extention(filename: str, allowed_extensions: list[str]): raise ValidationError(f"Invalid uploaded file extension: {extension}, Supported only {allowed_extensions} Files") +def validate_file_object(file): + validate_file_type(file) + if file.size > settings.DATA_UPLOAD_MAX_MEMORY_SIZE: + max_size_mb = settings.DATA_UPLOAD_MAX_MEMORY_SIZE // (1024 * 1024) + raise ValidationError(f"File size should not exceed {max_size_mb}MB.") + return file + + T = TypeVar("T", bound=models.Model) diff --git a/eap/views.py b/eap/views.py index 6ca9dbd2a..8211d73d3 100644 --- a/eap/views.py +++ b/eap/views.py @@ -20,7 +20,6 @@ EAPType, EnablingApproach, FullEAP, - KeyActor, PlannedOperation, SimplifiedEAP, ) @@ -66,7 +65,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: return ( super() .get_queryset() - .filter(status=EAPStatus.APPROVED) + .filter(status=EAPStatus.PROJECT_AGREEMENT_SIGNED) .select_related("disaster_type", "country") .annotate( requirement_cost=Case( @@ -275,15 +274,15 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: ), ), Prefetch( - "hazard_impact_images", + "hazard_impact_files", queryset=EAPFile.objects.select_related("created_by", "modified_by"), ), Prefetch( - "risk_selected_protocols_images", + "risk_selected_protocols_files", queryset=EAPFile.objects.select_related("created_by", "modified_by"), ), Prefetch( - "selected_early_actions_images", + "selected_early_actions_files", queryset=EAPFile.objects.select_related("created_by", "modified_by"), ), ) @@ -369,27 +368,24 @@ def get_queryset(self) -> QuerySet[FullEAP]: "evidence_base_source_of_information", "activation_process_source_of_information", # Files - "hazard_selection_images", + "hazard_selection_files", "theory_of_change_table_file", - "exposed_element_and_vulnerability_factor_images", - "prioritized_impact_images", + "exposed_element_and_vulnerability_factor_files", + "prioritized_impact_files", "risk_analysis_relevant_files", - "forecast_selection_images", - "definition_and_justification_impact_level_images", - "identification_of_the_intervention_area_images", + "forecast_selection_files", + "definition_and_justification_impact_level_files", + "identification_of_the_intervention_area_files", "trigger_model_relevant_files", - "early_action_selection_process_images", + "early_action_selection_process_files", "evidence_base_relevant_files", - "early_action_implementation_images", - "trigger_activation_system_images", + "early_action_implementation_files", + "trigger_activation_system_files", "activation_process_relevant_files", "meal_relevant_files", "capacity_relevant_files", "forecast_table_file", - Prefetch( - "key_actors", - queryset=KeyActor.objects.select_related("national_society"), - ), + "key_actors", Prefetch( "planned_operations", queryset=PlannedOperation.objects.prefetch_related( diff --git a/go-static/files/eap/budget_template.xlsm b/go-static/files/eap/budget_template.xlsm index 99eacf92b..5673eccbe 100644 Binary files a/go-static/files/eap/budget_template.xlsm and b/go-static/files/eap/budget_template.xlsm differ diff --git a/notifications/templates/email/eap/approved.html b/notifications/templates/email/eap/approved.html index 54b9d11f5..478ebdc31 100644 --- a/notifications/templates/email/eap/approved.html +++ b/notifications/templates/email/eap/approved.html @@ -1,33 +1,56 @@ + {% include "design/head3.html" %}
- +

Dear {{ national_society }} colleagues,

+ +

- We are glad to inform you that the {{ country_name }} {{ disaster_type }} is ready for implementation. Congratulations! + We are glad to inform you that the {{country_name}} {{disaster_type}} EAP has been approved by the DREF Appeal Manager. Congratulations!

- The IFRC Project should ensure that the transfer of funds for year 1 is done as soon as possible - and the NS should start the implementation of readiness for year 1 and pre-positioning activities. + The IFRC Project should start the PFA process right away and upload the PFA in GO within the next 14 days.


- If you have any questions on the process or the next steps, please don’t hesitate to reach out + If you have any questions on the process or the next steps, please don’t hesitate to reach out to DREF.anticipatorypillar@ifrc.org.


-

+

Attached documents:

+ +

Congratulations again and warm wishes,
- IFRC DREF AA Team + IFRC-DREF AA Team

-
diff --git a/notifications/templates/email/eap/pending_pfa.html b/notifications/templates/email/eap/pending_pfa.html deleted file mode 100644 index 478ebdc31..000000000 --- a/notifications/templates/email/eap/pending_pfa.html +++ /dev/null @@ -1,58 +0,0 @@ - -{% include "design/head3.html" %} - - - - - -
- -

- Dear {{ national_society }} colleagues, -

- - -
-

- We are glad to inform you that the {{country_name}} {{disaster_type}} EAP has been approved by the DREF Appeal Manager. Congratulations! -

- -

- The IFRC Project should start the PFA process right away and upload the PFA in GO within the next 14 days. -

- -
-

- If you have any questions on the process or the next steps, please don’t hesitate to reach out to - DREF.anticipatorypillar@ifrc.org. -

-
-

Attached documents:

- -

- Congratulations again and warm wishes,
- IFRC-DREF AA Team
-

-
- -{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/project_agreement_signed.html b/notifications/templates/email/eap/project_agreement_signed.html new file mode 100644 index 000000000..54b9d11f5 --- /dev/null +++ b/notifications/templates/email/eap/project_agreement_signed.html @@ -0,0 +1,35 @@ +{% include "design/head3.html" %} + + + + + +
+ +

+ Dear {{ national_society }} colleagues, +

+
+

+ We are glad to inform you that the {{ country_name }} {{ disaster_type }} is ready for implementation. Congratulations! +

+ +

+ The IFRC Project should ensure that the transfer of funds for year 1 is done as soon as possible + and the NS should start the implementation of readiness for year 1 and pre-positioning activities. +

+ +
+

+ If you have any questions on the process or the next steps, please don’t hesitate to reach out + DREF.anticipatorypillar@ifrc.org. +

+
+

+ Congratulations again and warm wishes,
+ IFRC DREF AA Team +

+ +
+ +{% include "design/foot1.html" %} diff --git a/notifications/templates/email/eap/technically_validated_eap.html b/notifications/templates/email/eap/technically_validated_eap.html index 94536bbe7..b2be93a57 100644 --- a/notifications/templates/email/eap/technically_validated_eap.html +++ b/notifications/templates/email/eap/technically_validated_eap.html @@ -12,7 +12,7 @@

- We are glad to inform you that the {{country_name}} {{disaster_type}} EAP has been Technically Validated. Congratulations! + We are glad to inform you that the {{country_name}} {{disaster_type}} EAP has been Technically validated by VC. Congratulations!

The Validation Committee expresses its thanks to {{ national_society }} and the IFRC delegation for all the work done in providing clear answers to all feedback. This is very much appreciated.