From fbcf6e355522f988acc57eb200bc5d90dc4fd9bc Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Wed, 4 Feb 2026 13:35:14 +0545 Subject: [PATCH] feat(eap): validation checks only on status transition - remove required fields for eap model - add validation checks step on status transition to under review --- assets | 2 +- eap/factories.py | 10 + ...eap_advance_financial_capacity_and_more.py | 543 ++++++++++++++++++ eap/models.py | 270 ++++++++- eap/serializers.py | 36 +- eap/test_views.py | 41 +- eap/utils.py | 27 + 7 files changed, 904 insertions(+), 25 deletions(-) create mode 100644 eap/migrations/0004_alter_fulleap_advance_financial_capacity_and_more.py diff --git a/assets b/assets index d98c2b844..6ac0d81d2 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d98c2b844a4246b2bedd29f6b2c87f5f32a7018d +Subproject commit 6ac0d81d25770ca3d066ac5a0d766859b13d4948 diff --git a/eap/factories.py b/eap/factories.py index 3dbe3fb1e..d210d7e3e 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -94,6 +94,16 @@ class Meta: ) 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-") + overall_objective_intervention = fuzzy.FuzzyText(length=20, prefix="overall-objective-") + potential_geographical_high_risk_areas = fuzzy.FuzzyText(length=20, prefix="potential-geographical-") + assisted_through_operation = fuzzy.FuzzyText(length=20, prefix="assisted-through-") + trigger_threshold_justification = fuzzy.FuzzyText(length=50, prefix="trigger-threshold-") + next_step_towards_full_eap = fuzzy.FuzzyText(length=50, prefix="next-step-") + early_action_capability = fuzzy.FuzzyText(length=50, prefix="early-action-") + rcrc_movement_involvement = fuzzy.FuzzyText(length=50, prefix="rcrc-movement-") @factory.post_generation def enabling_approaches(self, create, extracted, **kwargs): diff --git a/eap/migrations/0004_alter_fulleap_advance_financial_capacity_and_more.py b/eap/migrations/0004_alter_fulleap_advance_financial_capacity_and_more.py new file mode 100644 index 000000000..971126fb3 --- /dev/null +++ b/eap/migrations/0004_alter_fulleap_advance_financial_capacity_and_more.py @@ -0,0 +1,543 @@ +# Generated by Django 4.2.26 on 2026-02-03 09:23 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("eap", "0003_eapaction_eapcontact_eapfile_eapimpact_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="fulleap", + name="advance_financial_capacity", + field=models.TextField( + blank=True, + help_text="Indicate whether the NS has capacity to advance funds to start early actions.", + null=True, + verbose_name="National Society Financial capacity to advance funds", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="budget_description", + field=models.TextField( + blank=True, null=True, verbose_name="Full EAP Budget Description" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="budget_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="eap.eapfile", + verbose_name="Budget File", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="definition_and_justification_impact_level", + field=models.TextField( + blank=True, + null=True, + verbose_name="Definition and Justification of Impact Level", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="eap_endorsement", + field=models.TextField( + blank=True, + help_text="Describe by whom,how and when the EAP was agreed and endorsed.", + null=True, + verbose_name="EAP Endorsement Description", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Early Actions Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_cost_description", + field=models.TextField( + blank=True, null=True, verbose_name="Early Action Cost Description" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_implementation_process", + field=models.TextField( + blank=True, + help_text="Describe the process for implementing early actions.", + null=True, + verbose_name="Early Action Implementation Process", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="early_action_selection_process", + field=models.TextField( + blank=True, null=True, verbose_name="Early action selection process" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="evidence_base", + field=models.TextField( + blank=True, + help_text="Explain how the selected actions will reduce the expected disaster impacts.", + null=True, + verbose_name="Evidence base", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="exposed_element_and_vulnerability_factor", + field=models.TextField( + blank=True, + help_text="Explain which people are most likely to experience the impacts of this hazard.", + null=True, + verbose_name="Exposed elements and vulnerability factors", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="feasibility", + field=models.TextField( + blank=True, + help_text="Explain how feasible it is to implement the proposed early actions in the planned timeframe.", + null=True, + verbose_name="Feasibility of selected actions", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="forecast_selection", + field=models.TextField( + blank=True, + help_text="Explain which forecast's and observations will be used and why they are chosen", + null=True, + verbose_name="Forecast Selection", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="hazard_selection", + field=models.TextField( + blank=True, + help_text="Provide a brief rationale for selecting this hazard for the FbF system.", + null=True, + verbose_name="Hazard selection", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="identification_of_the_intervention_area", + field=models.TextField( + blank=True, + null=True, + verbose_name="Identification of Intervention Area", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_delegation_focal_point_email", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point email", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_delegation_focal_point_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point name", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_head_of_delegation_email", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation email", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="ifrc_head_of_delegation_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation name", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="key_actors", + field=models.ManyToManyField( + related_name="full_eap_key_actors", + to="eap.keyactor", + verbose_name="Key Actors", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="lead_time", + field=models.IntegerField(blank=True, null=True, verbose_name="Lead Time"), + ), + migrations.AlterField( + model_name="fulleap", + name="meal", + field=models.TextField( + blank=True, null=True, verbose_name="MEAL Plan Description" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="modified_at", + field=models.DateTimeField( + default=django.utils.timezone.now, verbose_name="modified at" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="operational_administrative_capacity", + field=models.TextField( + blank=True, + help_text="Describe how the NS has operative and administrative capacity to implement the EAPs.", + null=True, + verbose_name="National Society Operational, thematic and administrative capacity", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="pre_positioning_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Pre-positioning Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="prepositioning_cost_description", + field=models.TextField( + blank=True, null=True, verbose_name="Prepositioning Cost Description" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="prioritized_impact", + field=models.TextField( + blank=True, + help_text="Describe the impacts that have been prioritized and who is most likely to be affected.", + null=True, + verbose_name="Prioritized impact", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="readiness_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Readiness Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="readiness_cost_description", + field=models.TextField( + blank=True, null=True, verbose_name="Readiness Cost Description" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="selection_of_target_population", + field=models.TextField( + blank=True, + help_text="Describe the process used to select the target population for early actions.", + null=True, + verbose_name="Selection of Target Population", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="stop_mechanism", + field=models.TextField( + blank=True, + help_text="Explain how it would be communicated to communities and stakeholders that the activities are being stopped.", + null=True, + verbose_name="Stop Mechanism", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="strategies_and_plans", + field=models.TextField( + blank=True, + help_text="Describe how the EAP aligned with disaster risk management strategy of NS.", + null=True, + verbose_name="National Society Strategies and plans", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="total_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Total Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="fulleap", + name="trigger_activation_system", + field=models.TextField( + blank=True, + help_text="Describe the automatic system used to monitor the forecasts.", + null=True, + verbose_name="Trigger Activation System", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="trigger_statement", + field=models.TextField( + blank=True, + help_text="Explain in one sentence what exactly the trigger of your EAP will be.", + null=True, + verbose_name="Trigger Statement", + ), + ), + migrations.AlterField( + model_name="fulleap", + name="usefulness_of_actions", + field=models.TextField( + blank=True, + help_text="Describe how actions will still benefit the population if the expected event does not occur.", + null=True, + verbose_name="Usefulness of actions in case the event does not occur", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="assisted_through_operation", + field=models.TextField( + blank=True, null=True, verbose_name="Assisted through the operation" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="budget_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="eap.eapfile", + verbose_name="Budget File", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="early_action_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Early Actions Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="early_action_capability", + field=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.", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_email", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point email", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_delegation_focal_point_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC delegation focal point name", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_email", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation email", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="ifrc_head_of_delegation_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="IFRC head of delegation name", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="modified_at", + field=models.DateTimeField( + default=django.utils.timezone.now, verbose_name="modified at" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="next_step_towards_full_eap", + field=models.TextField( + blank=True, null=True, verbose_name="Next Steps towards Full EAP" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="operational_timeframe", + field=models.IntegerField( + blank=True, null=True, verbose_name="Operational Time" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="operational_timeframe_unit", + field=models.IntegerField( + blank=True, + choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], + default=20, + null=True, + verbose_name="Operational Timeframe Unit", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="overall_objective_intervention", + field=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", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="potential_geographical_high_risk_areas", + field=models.TextField( + blank=True, + null=True, + verbose_name="Potential geographical high-risk areas", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="pre_positioning_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Pre-positioning Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="prioritized_hazard_and_impact", + field=models.TextField( + blank=True, + null=True, + verbose_name="Prioritized Hazard and its historical impact.", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="rcrc_movement_involvement", + field=models.TextField( + blank=True, + help_text="RCRC Movement partners, Governmental/other agencies consulted/involved.", + null=True, + verbose_name="RCRC Movement Involvement.", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="readiness_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Readiness Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="risks_selected_protocols", + field=models.TextField( + blank=True, null=True, verbose_name="Risk selected for the protocols." + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="seap_lead_time", + field=models.IntegerField( + blank=True, null=True, verbose_name="sEAP Lead Time" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="seap_lead_timeframe_unit", + field=models.IntegerField( + blank=True, + choices=[(10, "Years"), (20, "Months"), (30, "Days"), (40, "Hours")], + null=True, + verbose_name="sEAP Lead Timeframe Unit", + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="selected_early_actions", + field=models.TextField( + blank=True, null=True, verbose_name="Selected Early Actions" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="total_budget", + field=models.IntegerField( + blank=True, null=True, verbose_name="Total Budget (CHF)" + ), + ), + migrations.AlterField( + model_name="simplifiedeap", + name="trigger_threshold_justification", + field=models.TextField( + blank=True, + help_text="Explain how the trigger were set and provide information", + null=True, + verbose_name="Trigger Threshold Justification", + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index 4656466cf..3dd4ea1cc 100644 --- a/eap/models.py +++ b/eap/models.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models, transaction +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from api.models import Admin2, Country, DisasterType, District @@ -831,31 +832,81 @@ class CommonEAPFields(models.Model): ) # Delegations - ifrc_delegation_focal_point_name = models.CharField(verbose_name=_("IFRC delegation focal point name"), max_length=255) - ifrc_delegation_focal_point_email = models.CharField(verbose_name=_("IFRC delegation focal point email"), max_length=255) + ifrc_delegation_focal_point_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 + 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 + verbose_name=_("IFRC delegation focal point phone number"), + max_length=100, + null=True, + blank=True, ) - ifrc_head_of_delegation_name = models.CharField(verbose_name=_("IFRC head of delegation name"), max_length=255) - ifrc_head_of_delegation_email = models.CharField(verbose_name=_("IFRC head of delegation email"), max_length=255) + ifrc_head_of_delegation_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 + 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 + 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_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 + verbose_name=_("Dref focal point phone number"), + max_length=100, + null=True, + blank=True, ) # Regional @@ -953,19 +1004,29 @@ class CommonEAPFields(models.Model): on_delete=models.CASCADE, verbose_name=_("Budget File"), related_name="+", + null=True, + blank=True, ) 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, ) # TYPING @@ -978,6 +1039,11 @@ class Meta: class SimplifiedEAP(EAPBaseModel, CommonEAPFields): """Model representing a Simplified EAP.""" + modified_at = models.DateTimeField( + verbose_name=_("modified at"), + default=timezone.now, + ) + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( EAPRegistration, on_delete=models.CASCADE, @@ -995,6 +1061,8 @@ 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, @@ -1005,6 +1073,8 @@ 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( @@ -1017,6 +1087,8 @@ 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, @@ -1029,14 +1101,20 @@ 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."), @@ -1055,9 +1133,13 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): seap_lead_timeframe_unit = models.IntegerField( choices=TimeFrame.choices, verbose_name=_("sEAP Lead Timeframe Unit"), + null=True, + blank=True, ) seap_lead_time = models.IntegerField( verbose_name=_("sEAP Lead Time"), + null=True, + blank=True, ) # NOTE: operational_timeframe_unit and operational_time are atomic @@ -1066,17 +1148,25 @@ class SimplifiedEAP(EAPBaseModel, CommonEAPFields): choices=TimeFrame.choices, default=TimeFrame.MONTHS, verbose_name=_("Operational Timeframe Unit"), + null=True, + blank=True, ) operational_timeframe = models.IntegerField( verbose_name=_("Operational Time"), + 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"), + null=True, + blank=True, ) # PLANNED OPEATIONS # @@ -1101,10 +1191,14 @@ 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 @@ -1182,10 +1276,44 @@ def generate_snapshot(self): self.save(update_fields=["is_locked"]) return instance + # NOTE: Add fields that are required for submission check validation + SUBMISSION_REQUIRED_FIELDS = [ + "planned_operations", + "enabling_approaches", + "total_budget", + "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", + "selected_early_actions", + "overall_objective_intervention", + "potential_geographical_high_risk_areas", + "assisted_through_operation", + "seap_lead_timeframe_unit", + "seap_lead_time", + "operational_timeframe_unit", + "operational_timeframe", + "trigger_threshold_justification", + "next_step_towards_full_eap", + "early_action_capability", + "rcrc_movement_involvement", + ] + class FullEAP(EAPBaseModel, CommonEAPFields): """Model representing a Full EAP.""" + modified_at = models.DateTimeField( + verbose_name=_("modified at"), + default=timezone.now, + ) + eap_registration = models.ForeignKey[EAPRegistration, EAPRegistration]( EAPRegistration, on_delete=models.CASCADE, @@ -1218,7 +1346,7 @@ class FullEAP(EAPBaseModel, CommonEAPFields): key_actors = models.ManyToManyField( KeyActor, verbose_name=_("Key Actors"), - related_name="full_eap_key_actor", + related_name="full_eap_key_actors", ) # TECHNICALLY WORKING GROUPS @@ -1243,6 +1371,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): hazard_selection = models.TextField( verbose_name=_("Hazard selection"), help_text=_("Provide a brief rationale for selecting this hazard for the FbF system."), + null=True, + blank=True, ) hazard_selection_images = models.ManyToManyField( @@ -1255,6 +1385,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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."), + null=True, + blank=True, ) exposed_element_and_vulnerability_factor_images = models.ManyToManyField( @@ -1267,6 +1399,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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."), + null=True, + blank=True, ) prioritized_impacts = models.ManyToManyField( @@ -1300,10 +1434,16 @@ class FullEAP(EAPBaseModel, CommonEAPFields): trigger_statement = models.TextField( verbose_name=_("Trigger Statement"), help_text=_("Explain in one sentence what exactly the trigger of your EAP will be."), + null=True, + blank=True, ) # NOTE: In days - lead_time = models.IntegerField(verbose_name=_("Lead Time")) + lead_time = models.IntegerField( + verbose_name=_("Lead Time"), + null=True, + blank=True, + ) trigger_statement_source_of_information = models.ManyToManyField( SourceInformation, @@ -1315,6 +1455,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): forecast_selection = models.TextField( verbose_name=_("Forecast Selection"), help_text=_("Explain which forecast's and observations will be used and why they are chosen"), + null=True, + blank=True, ) forecast_table_file = models.ForeignKey( @@ -1335,6 +1477,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): definition_and_justification_impact_level = models.TextField( verbose_name=_("Definition and Justification of Impact Level"), + null=True, + blank=True, ) definition_and_justification_impact_level_images = models.ManyToManyField( @@ -1346,6 +1490,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): identification_of_the_intervention_area = models.TextField( verbose_name=_("Identification of Intervention Area"), + null=True, + blank=True, ) identification_of_the_intervention_area_images = models.ManyToManyField( @@ -1379,6 +1525,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): early_action_selection_process = models.TextField( verbose_name=_("Early action selection process"), + null=True, + blank=True, ) early_action_selection_process_images = models.ManyToManyField( @@ -1387,7 +1535,6 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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, @@ -1400,6 +1547,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): evidence_base = models.TextField( verbose_name=_("Evidence base"), help_text="Explain how the selected actions will reduce the expected disaster impacts.", + null=True, + blank=True, ) evidence_base_relevant_files = models.ManyToManyField( @@ -1433,11 +1582,15 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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."), + null=True, + blank=True, ) 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."), + null=True, + blank=True, ) # EAP ACTIVATION PROCESS @@ -1445,6 +1598,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): early_action_implementation_process = models.TextField( verbose_name=_("Early Action Implementation Process"), help_text=_("Describe the process for implementing early actions."), + null=True, + blank=True, ) early_action_implementation_images = models.ManyToManyField( @@ -1457,6 +1612,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): trigger_activation_system = models.TextField( verbose_name=_("Trigger Activation System"), help_text=_("Describe the automatic system used to monitor the forecasts."), + null=True, + blank=True, ) trigger_activation_system_images = models.ManyToManyField( @@ -1469,6 +1626,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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."), + null=True, + blank=True, ) stop_mechanism = models.TextField( @@ -1476,6 +1635,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): help_text=_( "Explain how it would be communicated to communities and stakeholders that the activities are being stopped." ), + null=True, + blank=True, ) activation_process_relevant_files = models.ManyToManyField( @@ -1496,6 +1657,8 @@ class FullEAP(EAPBaseModel, CommonEAPFields): meal = models.TextField( verbose_name=_("MEAL Plan Description"), + null=True, + blank=True, ) meal_relevant_files = models.ManyToManyField( EAPFile, @@ -1508,14 +1671,20 @@ class FullEAP(EAPBaseModel, CommonEAPFields): 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."), + null=True, + blank=True, ) 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."), + null=True, + blank=True, ) 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."), + null=True, + blank=True, ) capacity_relevant_files = models.ManyToManyField( EAPFile, @@ -1526,16 +1695,34 @@ class FullEAP(EAPBaseModel, CommonEAPFields): # FINANCE AND LOGISTICS - budget_description = models.TextField(verbose_name=_("Full EAP Budget Description")) - readiness_cost_description = models.TextField(verbose_name=_("Readiness Cost Description")) - prepositioning_cost_description = models.TextField(verbose_name=_("Prepositioning Cost Description")) - early_action_cost_description = models.TextField(verbose_name=_("Early Action Cost Description")) + budget_description = models.TextField( + verbose_name=_("Full EAP Budget Description"), + null=True, + blank=True, + ) + readiness_cost_description = models.TextField( + verbose_name=_("Readiness Cost Description"), + null=True, + blank=True, + ) + prepositioning_cost_description = models.TextField( + verbose_name=_("Prepositioning Cost Description"), + null=True, + blank=True, + ) + early_action_cost_description = models.TextField( + verbose_name=_("Early Action Cost Description"), + null=True, + blank=True, + ) # 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."), + null=True, + blank=True, ) # NOTE: Snapshot fields @@ -1625,3 +1812,48 @@ def generate_snapshot(self): self.is_locked = True self.save(update_fields=["is_locked"]) return instance + + # NOTE: Add fields that are required for submission check validation + SUBMISSION_REQUIRED_FIELDS = [ + "planned_operations", + "enabling_approaches", + "total_budget", + "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", + "exposed_element_and_vulnerability_factor", + "prioritized_impact", + "prioritized_impacts", + "trigger_statement", + "lead_time", + "forecast_selection", + "forecast_table_file", + "definition_and_justification_impact_level", + "identification_of_the_intervention_area", + "early_actions", + "early_action_selection_process", + "theory_of_change_table_file", + "evidence_base", + "usefulness_of_actions", + "feasibility", + "early_action_implementation_process", + "trigger_activation_system", + "selection_of_target_population", + "stop_mechanism", + "meal", + "operational_administrative_capacity", + "strategies_and_plans", + "advance_financial_capacity", + "budget_description", + "readiness_cost_description", + "prepositioning_cost_description", + "early_action_cost_description", + "eap_endorsement", + ] diff --git a/eap/serializers.py b/eap/serializers.py index 7d655c21b..d56a08b9d 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -53,6 +53,7 @@ has_country_permission, is_user_ifrc_admin, validate_file_extention, + validate_for_under_review, ) from main.writable_nested_serializers import NestedCreateMixin, NestedUpdateMixin from utils.file_check import validate_file_type @@ -251,7 +252,6 @@ class Meta: read_only_fields = [ "status", "validated_budget_file", - "modified_at", "created_by", "modified_by", "latest_simplified_eap", @@ -458,9 +458,9 @@ def get_fields(self): # TODO(susilnem): Make admin2 required once we verify the data! fields["admin2_details"] = Admin2Serializer(source="admin2", many=True, read_only=True) fields["cover_image_file"] = EAPFileUpdateSerializer(source="cover_image", required=False, allow_null=True) - fields["planned_operations"] = PlannedOperationSerializer(many=True, required=True) - fields["enabling_approaches"] = EnablingApproachSerializer(many=True, required=True) - fields["budget_file"] = serializers.PrimaryKeyRelatedField(queryset=EAPFile.objects.all(), required=True) + fields["planned_operations"] = PlannedOperationSerializer(many=True, required=False) + fields["enabling_approaches"] = EnablingApproachSerializer(many=True, required=False) + fields["budget_file"] = serializers.PrimaryKeyRelatedField(queryset=EAPFile.objects.all(), required=False) fields["budget_file_details"] = EAPFileSerializer(source="budget_file", read_only=True) fields["updated_checklist_file_details"] = EAPFileSerializer(source="updated_checklist_file", read_only=True) return fields @@ -487,6 +487,26 @@ def validate_images_field(self, field_name, images): ) return images + def update(self, instance, validated_data): + modified_at = validated_data.pop("modified_at", None) + if not modified_at: + raise serializers.ValidationError( + { + "modified_at": gettext("modified_at is required for update operation."), + }, + ) + + # NOTE: Optimistic locking check + if modified_at and instance.modified_at and modified_at < instance.modified_at: + raise serializers.ValidationError( + { + "modified_at": gettext( + "The record has been modified since you last fetched it. Please refresh and try again." + ), + }, + ) + return super().update(instance, validated_data) + class SimplifiedEAPSerializer( NestedUpdateMixin, @@ -820,11 +840,19 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % (EAPRegistration.Status(current_status).label, EAPRegistration.Status(new_status).label) ) + # NOTE: Validating Simplified EAP before submission to under review + if new_status == EAPRegistration.Status.UNDER_REVIEW: + if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + validate_for_under_review(self.instance.latest_simplified_eap, SimplifiedEAP.SUBMISSION_REQUIRED_FIELDS) + else: + validate_for_under_review(self.instance.latest_full_eap, FullEAP.SUBMISSION_REQUIRED_FIELDS) + if (current_status, new_status) == ( EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.UNDER_REVIEW, ): if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + # NOTE: Generating PDF asynchronously transaction.on_commit( lambda: generate_export_eap_pdf.delay( eap_registration_id=self.instance.id, diff --git a/eap/test_views.py b/eap/test_views.py index e1d814de7..5d10a88ea 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -1,5 +1,6 @@ import os import tempfile +from datetime import datetime from unittest import mock from django.conf import settings @@ -790,6 +791,7 @@ def test_update_simplified_eap(self): url = f"/api/v2/simplified-eap/{simplified_eap.id}/" data = { + "modified_at": datetime.now(), "eap_registration": eap_registration.id, "total_budget": 20000, "readiness_budget": 8000, @@ -1185,6 +1187,19 @@ def test_status_transition(self): response = self.client.post(self.url, data, format="json") self.assertEqual(response.status_code, 400) + enabling_approach = EnablingApproachFactory.create( + approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, + budget_per_approach=5000, + ap_code=123, + ) + + planned_operation = PlannedOperationFactory.create( + sector=PlannedOperation.Sector.SHELTER, + ap_code=456, + people_targeted=5000, + budget_per_sector=50000, + ) + simplified_eap = SimplifiedEAPFactory.create( eap_registration=self.eap_registration, created_by=self.country_admin, @@ -1193,6 +1208,8 @@ def test_status_transition(self): created_by=self.country_admin, modified_by=self.country_admin, ), + planned_operations=[planned_operation.id], + enabling_approaches=[enabling_approach.id], ) self.eap_registration.latest_simplified_eap = simplified_eap self.eap_registration.save() @@ -1310,6 +1327,7 @@ def test_status_transition(self): modified_by=self.country_admin, ) file_data = { + "modified_at": datetime.now(), "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "eap_registration": second_snapshot.eap_registration_id, "updated_checklist_file": checklist_file_instance.id, @@ -1398,6 +1416,7 @@ def test_status_transition(self): # UPDATES on the second snapshot url = f"/api/v2/simplified-eap/{third_snapshot.id}/" file_data = { + "modified_at": datetime.now(), "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "risks_selected_protocols": "Protocol A and Protocol B.", "selected_early_actions": "The early actions selected.", @@ -1511,6 +1530,7 @@ def test_status_transition(self): # UPDATES on the second snapshot url = f"/api/v2/simplified-eap/{fourth_snapshot.id}/" file_data = { + "modified_at": datetime.now(), "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "risks_selected_protocols": "Protocol A and Protocol B.", "selected_early_actions": "The early actions selected.", @@ -1704,6 +1724,19 @@ def test_status_transitions_trigger_email( created_by=self.country_admin, modified_by=self.country_admin, ) + + enabling_approach = EnablingApproachFactory.create( + approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, + budget_per_approach=5000, + ap_code=123, + ) + + planned_operation = PlannedOperationFactory.create( + sector=PlannedOperation.Sector.SHELTER, + ap_code=456, + people_targeted=5000, + budget_per_sector=50000, + ) simplified_eap = SimplifiedEAPFactory.create( eap_registration=eap_registration, created_by=self.country_admin, @@ -1712,6 +1745,8 @@ def test_status_transitions_trigger_email( created_by=self.country_admin, modified_by=self.country_admin, ), + planned_operations=[planned_operation.id], + enabling_approaches=[enabling_approach.id], ) eap_registration.latest_simplified_eap = simplified_eap eap_registration.save() @@ -1773,6 +1808,7 @@ def test_status_transitions_trigger_email( modified_by=self.country_admin, ) file_data = { + "modified_at": datetime.now(), "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "eap_registration": snapshot.eap_registration_id, "updated_checklist_file": checklist_file_instance.id, @@ -1836,6 +1872,7 @@ def test_status_transitions_trigger_email( modified_by=self.country_admin, ) file_data = { + "modified_at": datetime.now(), "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "eap_registration": snapshot.eap_registration_id, "updated_checklist_file": checklist_file_instance.id, @@ -1906,6 +1943,7 @@ def test_status_transitions_trigger_email( modified_by=self.country_admin, ) file_data = { + "modified_at": datetime.now(), "prioritized_hazard_and_impact": "Floods with potential heavy impact.", "eap_registration": snapshot.eap_registration_id, "updated_checklist_file": checklist_file_instance.id, @@ -2210,6 +2248,7 @@ def test_create_full_eap(self): # Create EAP Registration eap_registration = EAPRegistrationFactory.create( eap_type=EAPType.FULL_EAP, + status=EAPStatus.UNDER_DEVELOPMENT, country=self.country, national_society=self.national_society, disaster_type=self.disaster_type, @@ -2502,8 +2541,8 @@ def test_update_full_eap(self): url = f"/api/v2/full-eap/{full_eap.id}/" data = { + "modified_at": datetime.now(), "total_budget": 20000, - "seap_timeframe": 5, "key_actors": [ { "national_society": self.national_society.id, diff --git a/eap/utils.py b/eap/utils.py index 04f25d5fa..86e76974f 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -5,6 +5,9 @@ from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError from django.db import models +from django.db.models.fields.related import ManyToManyField +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers from api.models import Region, RegionName from eap.models import EAPType, FullEAP, SimplifiedEAP @@ -242,3 +245,27 @@ def copy_model_instance( getattr(clone, name).set(cloned_related) return clone + + +def validate_for_under_review(instance: models.Model, required_fields: list[str]) -> None: + """ + Validates that all required fields are filled before submission. + Raises a ValidationError if any required field is missing. + """ + + errors = {} + + for field_name in required_fields: + field = instance._meta.get_field(field_name) + value = getattr(instance, field_name) + + if isinstance(field, ManyToManyField): + if not value.exists(): + errors[field_name] = _("This field is required before submission.") + + else: + if value in (None, "", []): + errors[field_name] = _("This field is required before submission.") + + if errors: + raise serializers.ValidationError(errors)