diff --git a/assets b/assets index d685cc197..aad59acef 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d685cc19773c32242fc67787fafc8043172f1308 +Subproject commit aad59acefa6d4506ac0521a1530c4e0672a7804c diff --git a/eap/admin.py b/eap/admin.py index e6e7a107e..446919cc0 100644 --- a/eap/admin.py +++ b/eap/admin.py @@ -14,6 +14,11 @@ @admin.register(EAPFile) class EAPFileAdmin(admin.ModelAdmin): search_fields = ("caption",) + list_select_related = True + autocomplete_fields = ( + "created_by", + "modified_by", + ) @admin.register(EAPRegistration) @@ -85,12 +90,13 @@ class SimplifiedEAPAdmin(admin.ModelAdmin): "eap_registration__country__name", "eap_registration__disaster_type__name", ) - list_display = ("simplifed_eap_application", "version", "is_locked") + list_display = ("simplifed_eap_application", "eap_registration", "version", "is_locked") autocomplete_fields = ( "eap_registration", "created_by", "modified_by", "admin2", + "partners", ) readonly_fields = ( "cover_image", @@ -159,6 +165,7 @@ def get_queryset(self, request): ) .prefetch_related( "admin2", + "partners", "partner_contacts", ) ) @@ -176,12 +183,13 @@ class FullEAPAdmin(admin.ModelAdmin): "eap_registration__country__name", "eap_registration__disaster_type__name", ) - list_display = ("eap_registration",) + list_display = ("full_eap_application", "eap_registration", "version", "is_locked") autocomplete_fields = ( "eap_registration", "created_by", "modified_by", "admin2", + "partners", ) readonly_fields = ( "partner_contacts", @@ -244,6 +252,9 @@ def regenerate_diff_pdf_file(self, request, queryset): regenerate_diff_pdf_file.short_description = "Regenerate EAP diff PDF files for selected Full EAPs" + def full_eap_application(self, obj): + return f"{obj.eap_registration.national_society.society_name} - {obj.eap_registration.disaster_type.name}" + def get_queryset(self, request): return ( super() @@ -258,6 +269,7 @@ def get_queryset(self, request): ) .prefetch_related( "admin2", + "partners", "partner_contacts", "key_actors", "risk_analysis_source_of_information", diff --git a/eap/factories.py b/eap/factories.py index 068027a4e..b4b2c1784 100644 --- a/eap/factories.py +++ b/eap/factories.py @@ -138,7 +138,6 @@ class Meta: approach = fuzzy.FuzzyChoice(EnablingApproach.Approach) budget_per_approach = fuzzy.FuzzyInteger(1000, 1000000) - ap_code = fuzzy.FuzzyInteger(100, 999) @factory.post_generation def readiness_activities(self, create, extracted, **kwargs): @@ -175,7 +174,6 @@ class Meta: sector = fuzzy.FuzzyChoice(PlannedOperation.Sector) people_targeted = fuzzy.FuzzyInteger(100, 100000) budget_per_sector = fuzzy.FuzzyInteger(1000, 1000000) - ap_code = fuzzy.FuzzyInteger(100, 999) @factory.post_generation def readiness_activities(self, create, extracted, **kwargs): diff --git a/eap/migrations/0007_remove_eapregistration_activated_at_and_more.py b/eap/migrations/0007_remove_eapregistration_activated_at_and_more.py new file mode 100644 index 000000000..56ca12787 --- /dev/null +++ b/eap/migrations/0007_remove_eapregistration_activated_at_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.29 on 2026-03-16 09:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eap', '0006_fulleap_meal_source_of_information_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='eapregistration', + name='activated_at', + ), + migrations.AddField( + model_name='fulleap', + name='lead_timeframe_unit', + field=models.IntegerField(blank=True, choices=[(10, 'Years'), (20, 'Months'), (30, 'Days'), (40, 'Hours')], null=True, verbose_name='Lead Timeframe Unit'), + ), + migrations.AlterField( + model_name='eapregistration', + name='status', + field=models.IntegerField(choices=[(10, 'Under Development'), (20, 'Under Review'), (30, 'NS Addressing Comments'), (40, 'Technically Validated'), (50, 'Approved(Pending PFA)'), (60, 'Approved')], default=10, help_text='Select the current status of the EAP development process.', verbose_name='EAP Status'), + ), + migrations.AlterField( + model_name='enablingapproach', + name='ap_code', + field=models.IntegerField(default=1, verbose_name='AP Code'), + preserve_default=False, + ), + ] diff --git a/eap/migrations/0008_remove_enablingapproach_ap_code_and_more.py b/eap/migrations/0008_remove_enablingapproach_ap_code_and_more.py new file mode 100644 index 000000000..8fcf94b56 --- /dev/null +++ b/eap/migrations/0008_remove_enablingapproach_ap_code_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.28 on 2026-04-03 09:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0228_merge_20260123_0806'), + ('eap', '0007_remove_eapregistration_activated_at_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='enablingapproach', + name='ap_code', + ), + migrations.RemoveField( + model_name='plannedoperation', + name='ap_code', + ), + migrations.AddField( + model_name='fulleap', + name='partners', + field=models.ManyToManyField(blank=True, help_text='Select any partner NS involved in the EAP development.', related_name='+', to='api.country', verbose_name='Partners'), + ), + migrations.AddField( + model_name='simplifiedeap', + name='partners', + field=models.ManyToManyField(blank=True, help_text='Select any partner NS involved in the EAP development.', related_name='+', to='api.country', verbose_name='Partners'), + ), + migrations.AlterField( + model_name='plannedoperation', + name='sector', + field=models.IntegerField(choices=[(101, 'Shelter, Settlement and Housing'), (102, 'Livelihoods'), (103, 'Protection, Gender and Inclusion'), (104, 'Health and Care'), (105, 'Risk Reduction, Climate Adaptation and Recovery'), (106, 'Multipurpose Cash'), (107, 'Water, Sanitation And Hygiene'), (109, 'Education'), (110, 'Migration'), (111, 'Environment Sustainability'), (112, 'Community Engagement And Accountability')], verbose_name='sector'), + ), + ] diff --git a/eap/models.py b/eap/models.py index 699a54db6..b93f4ea40 100644 --- a/eap/models.py +++ b/eap/models.py @@ -386,16 +386,30 @@ class Sector(models.IntegerChoices): RISK_REDUCTION_CLIMATE_ADAPTATION_AND_RECOVERY = 105, _("Risk Reduction, Climate Adaptation and Recovery") MULTIPURPOSE_CASH = 106, _("Multipurpose Cash") WATER_SANITATION_AND_HYGIENE = 107, _("Water, Sanitation And Hygiene") - WASH = 108, _("WASH") EDUCATION = 109, _("Education") MIGRATION = 110, _("Migration") ENVIRONMENT_SUSTAINABILITY = 111, _("Environment Sustainability") COMMUNITY_ENGAGEMENT_AND_ACCOUNTABILITY = 112, _("Community Engagement And Accountability") + @classmethod + def get_sector_ap_codes(cls) -> dict[int, list[str]]: + return { + cls.SHELTER_SETTLEMENT_AND_HOUSING: ["AP101", "AP103", "AP104"], + cls.LIVELIHOODS: ["AP007"], + cls.PROTECTION_GENDER_AND_INCLUSION: ["AP114", "AP116", "AP117"], + cls.HEALTH_AND_CARE: ["AP107", "AP108", "AP109"], + cls.RISK_REDUCTION_CLIMATE_ADAPTATION_AND_RECOVERY: ["AP101", "AP103", "AP104", "AP105", "AP106"], + cls.MULTIPURPOSE_CASH: ["AP081"], + cls.WATER_SANITATION_AND_HYGIENE: ["AP110", "AP111"], + cls.EDUCATION: ["AP115"], + cls.MIGRATION: ["AP112", "AP113"], + cls.ENVIRONMENT_SUSTAINABILITY: ["AP102"], + cls.COMMUNITY_ENGAGEMENT_AND_ACCOUNTABILITY: ["AP129"], + } + sector = models.IntegerField(choices=Sector.choices, verbose_name=_("sector")) people_targeted = models.IntegerField(verbose_name=_("People Targeted")) budget_per_sector = models.IntegerField(verbose_name=_("Budget per sector (CHF)")) - ap_code = models.IntegerField(verbose_name=_("AP Code")) previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) indicators = models.ManyToManyField( @@ -439,9 +453,24 @@ class Approach(models.IntegerChoices): NATIONAL_SOCIETY_STRENGTHENING = 20, _("National Society Strengthening") PARTNERSHIP_AND_COORDINATION = 30, _("Partnership And Coordination") + @classmethod + def get_approach_ap_codes(cls) -> dict[int, list[str]]: + return { + cls.SECRETARIAT_SERVICES: ["AP122"], + cls.NATIONAL_SOCIETY_STRENGTHENING: ["AP124", "AP125", "AP126"], + cls.PARTNERSHIP_AND_COORDINATION: [ + "AP049", + "AP118", + "AP119", + "AP120", + "AP121", + "AP127", + "AP128", + ], + } + approach = models.IntegerField(choices=Approach.choices, verbose_name=_("Approach")) budget_per_approach = models.IntegerField(verbose_name=_("Budget per approach (CHF)")) - ap_code = models.IntegerField(verbose_name=_("AP Code"), null=True, blank=True) previous_id = models.PositiveIntegerField(verbose_name=_("Previous ID"), null=True, blank=True) indicators = models.ManyToManyField( @@ -553,7 +582,7 @@ class EAPStatus(models.IntegerChoices): IFRC can change status to NS_ADDRESSING_COMMENTS or PENDING_PFA. """ - PENDING_PFA = 50, _("Pending PFA") + PENDING_PFA = 50, _("Approved(Pending PFA)") """EAP is in the process of signing the PFA between IFRC and NS. """ @@ -562,9 +591,6 @@ class EAPStatus(models.IntegerChoices): Cannot be changed back to previous statuses. """ - ACTIVATED = 70, _("Activated") - """EAP has been activated""" - # BASE MODEL FOR EAP class EAPRegistration(EAPBaseModel): @@ -722,12 +748,6 @@ class EAPRegistration(EAPBaseModel): verbose_name=_("pending pfa at"), help_text=_("Timestamp when the EAP was marked as pending PFA."), ) - activated_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_("activated at"), - help_text=_("Timestamp when the EAP was activated."), - ) # EAP submission deadline deadline = models.DateField( @@ -808,6 +828,14 @@ class CommonEAPFields(models.Model): related_name="+", ) + partners = models.ManyToManyField( + Country, + verbose_name=_("Partners"), + help_text=_("Select any partner NS involved in the EAP development."), + related_name="+", + blank=True, + ) + people_targeted = models.IntegerField( verbose_name=_("People Targeted."), blank=True, @@ -1247,7 +1275,7 @@ class Meta: ] def __str__(self): - return f"Simplified EAP for {self.eap_registration}- version:{self.version}" + return f"{self.eap_registration} (VERSION {self.version})" def generate_snapshot(self): """ @@ -1263,6 +1291,7 @@ def generate_snapshot(self): overrides={ "parent_id": self.id, "version": self.version + 1, + "is_locked": False, "created_by_id": self.created_by_id, "modified_by_id": self.modified_by_id, "review_checklist_file": None, @@ -1272,6 +1301,7 @@ def generate_snapshot(self): }, exclude_clone_m2m_fields={ "admin2", + "partners", "cover_image", "hazard_impact_images", "risk_selected_protocols_images", @@ -1449,13 +1479,19 @@ class FullEAP(EAPBaseModel, CommonEAPFields): ) # NOTE: In days - # TODO(susilnem): add unit for lead time lead_time = models.IntegerField( verbose_name=_("Lead Time"), null=True, blank=True, ) + lead_timeframe_unit = models.IntegerField( + choices=TimeFrame.choices, + verbose_name=_("Lead Timeframe Unit"), + null=True, + blank=True, + ) + trigger_statement_source_of_information = models.ManyToManyField( SourceInformation, verbose_name=_("Trigger Statement Source of Forecast"), @@ -1788,7 +1824,7 @@ class Meta: ] def __str__(self): - return f"Full EAP for {self.eap_registration}- version:{self.version}" + return f"{self.eap_registration} (VERSION {self.version})" def generate_snapshot(self): """ @@ -1803,6 +1839,7 @@ def generate_snapshot(self): overrides={ "parent_id": self.id, "version": self.version + 1, + "is_locked": False, "created_by_id": self.created_by_id, "modified_by_id": self.modified_by_id, "review_checklist_file": None, @@ -1812,6 +1849,7 @@ def generate_snapshot(self): }, exclude_clone_m2m_fields={ "admin2", + "partners", "cover_image", # Files "hazard_selection_images", diff --git a/eap/serializers.py b/eap/serializers.py index 53352d415..5d2bdddee 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -215,10 +215,12 @@ class MiniFullEAPSerializer( ): updated_checklist_file_details = EAPFileSerializer(source="updated_checklist_file", read_only=True) budget_file_details = EAPFileSerializer(source="budget_file", read_only=True) + theory_of_change_table_file_details = EAPFileSerializer(source="theory_of_change_table_file", read_only=True) + forecast_table_file_details = EAPFileSerializer(source="forecast_table_file", read_only=True) class Meta: model = FullEAP - fields = [ + fields = ( "id", "total_budget", "readiness_budget", @@ -230,9 +232,11 @@ class Meta: "is_locked", "review_checklist_file", "updated_checklist_file_details", + "theory_of_change_table_file_details", + "forecast_table_file_details", "created_at", "modified_at", - ] + ) class MiniEAPSerializer(serializers.ModelSerializer): @@ -255,7 +259,6 @@ class Meta: "status", "status_display", "requirement_cost", - "activated_at", "approved_at", "created_at", "modified_at", @@ -498,6 +501,7 @@ def get_fields(self): fields["budget_file"] = serializers.PrimaryKeyRelatedField( queryset=EAPFile.objects.all(), required=False, allow_null=True ) + fields["partners_details"] = MiniCountrySerializer(source="partners", many=True, read_only=True) fields["budget_file_details"] = EAPFileSerializer(source="budget_file", read_only=True) fields["updated_checklist_file_details"] = EAPFileSerializer(source="updated_checklist_file", read_only=True) return fields @@ -575,6 +579,8 @@ class Meta: read_only_fields = [ "version", "is_locked", + "created_by", + "modified_by", ] exclude = ("cover_image",) @@ -627,6 +633,11 @@ def _validate_timeframe(self, data: dict[str, typing.Any]) -> None: {"operational_timeframe": gettext("operational timeframe value is not valid for Months unit.")} ) + def validate_eap_registration(self, eap_registration: EAPRegistration) -> EAPRegistration: + if not self.instance and eap_registration.has_eap_application: + raise serializers.ValidationError("EAP for this registration has already been created.") + return eap_registration + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: original_eap_registration = getattr(self.instance, "eap_registration", None) if self.instance else None eap_registration: EAPRegistration | None = data.get("eap_registration", original_eap_registration) @@ -635,9 +646,6 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: if self.instance and original_eap_registration != eap_registration: raise serializers.ValidationError("EAP Registration cannot be changed for existing EAP.") - if not self.instance and eap_registration.has_eap_application: - raise serializers.ValidationError("Simplified EAP for this EAP registration already exists.") - if self.instance and eap_registration.get_status_enum not in [ EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.NS_ADDRESSING_COMMENTS, @@ -778,12 +786,33 @@ class FullEAPSerializer( class Meta: model = FullEAP - read_only_fields = ( + read_only_fields = [ + "version", + "is_locked", "created_by", "modified_by", - ) + ] exclude = ("cover_image",) + def _validate_timeframe(self, data: dict[str, typing.Any]) -> None: + lead_unit = data.get("lead_timeframe_unit") + lead_time_value = data.get("lead_time") + + if lead_time_value is not None and lead_unit is None: + raise serializers.ValidationError( + { + "lead_timeframe_unit": gettext("lead timeframe and unit must both be provided."), + } + ) + + 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.")}) + + def validate_eap_registration(self, eap_registration: EAPRegistration) -> EAPRegistration: + if not self.instance and eap_registration.has_eap_application: + raise serializers.ValidationError("EAP for this registration has already been created.") + return eap_registration + def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: original_eap_registration = getattr(self.instance, "eap_registration", None) if self.instance else None eap_registration: EAPRegistration | None = data.get("eap_registration", original_eap_registration) @@ -792,16 +821,13 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: if self.instance and original_eap_registration != eap_registration: raise serializers.ValidationError("EAP Registration cannot be changed for existing EAP.") - if not self.instance and eap_registration.has_eap_application: - raise serializers.ValidationError("Full EAP for this EAP registration already exists.") - if self.instance and eap_registration.get_status_enum not in [ EAPRegistration.Status.UNDER_DEVELOPMENT, EAPRegistration.Status.NS_ADDRESSING_COMMENTS, ]: raise serializers.ValidationError( - gettext("Cannot update while EAP Application is in %s."), - EAPRegistration.Status(eap_registration.get_status_enum).label, + gettext("Cannot update while EAP Application is in %s.") + % EAPRegistration.Status(eap_registration.get_status_enum).label ) # NOTE: Cannot update locked Full EAP @@ -812,6 +838,9 @@ def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]: if eap_type and eap_type != EAPType.FULL_EAP: raise serializers.ValidationError("Cannot create Full EAP for non-full EAP registration.") + # Validate timeframe fields + self._validate_timeframe(data) + # Validate all image fields in one place for field in self.IMAGE_FIELDS: if field in data: @@ -844,7 +873,6 @@ def create(self, validated_data: dict[str, typing.Any]): (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.NS_ADDRESSING_COMMENTS), (EAPRegistration.Status.TECHNICALLY_VALIDATED, EAPRegistration.Status.PENDING_PFA), (EAPRegistration.Status.PENDING_PFA, EAPRegistration.Status.APPROVED), - (EAPRegistration.Status.APPROVED, EAPRegistration.Status.ACTIVATED), ] ) @@ -894,7 +922,9 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t EAPRegistration.Status.UNDER_REVIEW, ): if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: - # NOTE: Generating PDF asynchronously + self.instance.latest_simplified_eap.is_locked = True + self.instance.latest_simplified_eap.save(update_fields=["is_locked"]) + # NOTE: Generating export PDF asynchronously transaction.on_commit( lambda: generate_export_eap_pdf.delay( eap_registration_id=self.instance.id, @@ -902,6 +932,9 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ) ) else: + self.instance.latest_full_eap.is_locked = True + self.instance.latest_full_eap.save(update_fields=["is_locked"]) + # NOTE: Generate export PDF asynchronously transaction.on_commit( lambda: generate_export_eap_pdf.delay( eap_registration_id=self.instance.id, @@ -927,19 +960,12 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t % EAPRegistration.Status(new_status).label ) - # latest EAP if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: - snapshot_instance = self.instance.latest_simplified_eap.generate_snapshot() - self.instance.latest_simplified_eap = snapshot_instance - snapshot_instance.review_checklist_file = review_checklist_file - snapshot_instance.save(update_fields=["review_checklist_file"]) - self.instance.save(update_fields=["latest_simplified_eap"]) + self.instance.latest_simplified_eap.review_checklist_file = review_checklist_file + self.instance.latest_simplified_eap.save(update_fields=["review_checklist_file"]) else: - snapshot_instance = self.instance.latest_full_eap.generate_snapshot() - self.instance.latest_full_eap = snapshot_instance - snapshot_instance.review_checklist_file = review_checklist_file - snapshot_instance.save(update_fields=["review_checklist_file"]) - self.instance.save(update_fields=["latest_full_eap"]) + self.instance.latest_full_eap.review_checklist_file = review_checklist_file + self.instance.latest_full_eap.save(update_fields=["review_checklist_file"]) # NOTE: Clearing validated budget file, if changes to NS Addressing Comments. if (current_status, new_status) == ( @@ -947,7 +973,13 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t EAPRegistration.Status.NS_ADDRESSING_COMMENTS, ): self.instance.validated_budget_file = None - self.instance.save(update_fields=["validated_budget_file"]) + self.instance.technically_validated_at = None + self.instance.save( + update_fields=[ + "validated_budget_file", + "technically_validated_at", + ] + ) elif (current_status, new_status) == ( EAPRegistration.Status.UNDER_REVIEW, @@ -977,6 +1009,13 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t # Check latest EAP has NS Addressing Comments file uploaded if self.instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + # NOTE: If EAP is locked, NS has to revise the EAP to address the comments before resubmitting. + if self.instance.latest_simplified_eap.is_locked: + raise serializers.ValidationError( + gettext("Cannot change status to %s, Please revise the EAP to address the comments.") + % EAPRegistration.Status(new_status).label + ) + if not (self.instance.latest_simplified_eap and self.instance.latest_simplified_eap.updated_checklist_file): raise serializers.ValidationError( { @@ -987,6 +1026,10 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t }, ) + # Lock the latest eap + self.instance.latest_simplified_eap.is_locked = True + self.instance.latest_simplified_eap.save(update_fields=["is_locked"]) + # Generating PDFs asynchronously transaction.on_commit( lambda: group( @@ -1002,6 +1045,12 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ) else: + if self.instance.latest_full_eap.is_locked: + raise serializers.ValidationError( + gettext("Cannot change status to %s, Please revise the EAP to address the comments.") + % EAPRegistration.Status(new_status).label + ) + if not (self.instance.latest_full_eap and self.instance.latest_full_eap.updated_checklist_file): raise serializers.ValidationError( { @@ -1012,6 +1061,10 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t }, ) + # Lock the latest full eap + self.instance.latest_full_eap.is_locked = True + self.instance.latest_full_eap.save(update_fields=["is_locked"]) + # Generating PDFs asynchronously transaction.on_commit( lambda: group( @@ -1065,17 +1118,6 @@ def _validate_status(self, validated_data: dict[str, typing.Any]) -> dict[str, t ] ) - elif (current_status, new_status) == ( - EAPRegistration.Status.APPROVED, - EAPRegistration.Status.ACTIVATED, - ): - # Update timestamp - self.instance.activated_at = timezone.now() - self.instance.save( - update_fields=[ - "activated_at", - ] - ) return validated_data def validate(self, validated_data: dict[str, typing.Any]) -> dict[str, typing.Any]: @@ -1118,31 +1160,30 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any ]: """ NOTE: - At the transition (UNDER_REVIEW -> NS_ADDRESSING_COMMENTS), the EAP snapshot - is generated inside `_validate_status()` BEFORE we reach this `update()` method. - + At the transition (UNDER_REVIEW -> NS_ADDRESSING_COMMENTS), + after revise of eap, new snapshot will be created with incremented version. That snapshot operation: - Locks the reviewed EAP (previous version) - Creates a new snapshot (incremented version) - Updates latest_simplified_eap or latest_full_eap to the new version Email logic based on eap_count: - - If eap_count == 2 (i.e., first snapshot already exists and this is the first IFRC feedback cycle) + - If eap_count == 1 (i.e., first eap has been created), indicating the first feedback cycle: - Send the first feedback email - - Else (eap_count > 2), indicating subsequent feedback cycles: + - Else (eap_count > 1), indicating subsequent feedback cycles: - Send the resubmitted feedback email Therefore: - - version == 2 always corresponds to the first IFRC feedback cycle - - Any later versions (>= 3) correspond to resubmitted cycles - - Also when the IFRC resubmits after technical validation, it will be version >= 3 + - version == 1 always corresponds to the first IFRC feedback cycle + - Any later versions (>1) correspond to resubmitted cycles + - Also when the IFRC resubmits after technical validation, it will be version > 2 Deadline update rules: - First IFRC feedback cycle: deadline is set to 90 days from the current date. - Subsequent feedback or resubmission cycles: deadline is set to 30 days from the current date. """ - if eap_count == 2: + if eap_count == 1: updated_instance.deadline = timezone.now().date() + timedelta(days=90) updated_instance.save( update_fields=[ @@ -1151,7 +1192,7 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any ) transaction.on_commit(lambda: send_feedback_email.delay(eap_registration_id)) - elif eap_count > 2: + elif eap_count > 1: updated_instance.deadline = timezone.now().date() + timedelta(days=30) updated_instance.save( update_fields=[ @@ -1185,3 +1226,8 @@ def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any transaction.on_commit(lambda: send_approved_email.delay(eap_registration_id)) return updated_instance + + +class EAPOptionsSerializer(serializers.Serializer): + sector_ap_codes = serializers.DictField(child=serializers.ListField(child=serializers.CharField())) + approach_ap_codes = serializers.DictField(child=serializers.ListField(child=serializers.CharField())) diff --git a/eap/tasks.py b/eap/tasks.py index 0b999b4d9..11d5d9451 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -217,8 +217,7 @@ def send_new_eap_submission_email(eap_registration_id: int): eap_registration_id=instance.id, version=latest_eap.version, ) - partner_contacts = latest_eap.partner_contacts - partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + partner_ns_emails = list(latest_eap.partner_contacts.values_list("email", flat=True)) regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -265,8 +264,7 @@ def send_feedback_email(eap_registration_id: int): ifrc_delegation_focal_point_email = latest_eap.ifrc_delegation_focal_point_email - partner_contacts = latest_eap.partner_contacts - partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + partner_ns_emails = list(latest_eap.partner_contacts.values_list("email", flat=True)) regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -314,16 +312,13 @@ def send_eap_resubmission_email(eap_registration_id: int): else: latest_eap = instance.latest_full_eap - latest_version = latest_eap.version - if not latest_eap.diff_file: generate_export_diff_pdf( eap_registration_id=instance.id, version=latest_eap.version, ) - partner_contacts = latest_eap.partner_contacts - partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + partner_ns_emails = list(latest_eap.partner_contacts.values_list("email", flat=True)) regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -345,7 +340,7 @@ def send_eap_resubmission_email(eap_registration_id: int): email_context = get_eap_email_context(instance) email_subject = ( f"[DREF {instance.get_eap_type_display()} FOR REVIEW] " - f"{instance.country} {instance.disaster_type} version {latest_version} TO THE IFRC-DREF" + f"{instance.country} {instance.disaster_type} version {latest_eap.version} TO THE IFRC-DREF" ) email_body = render_to_string("email/eap/re-submission.html", email_context) email_type = "Feedback to the National Society" @@ -369,26 +364,10 @@ def send_feedback_email_for_resubmitted_eap(eap_registration_id: int): if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: latest_eap = instance.latest_simplified_eap - eap_model = SimplifiedEAP else: latest_eap = instance.latest_full_eap - eap_model = FullEAP - - latest_version = latest_eap.version - partner_contacts = latest_eap.partner_contacts - partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) - - previous_eap = ( - eap_model.objects.filter( - eap_registration=instance, - version__lt=latest_version, - ) - .order_by("-version") - .first() - ) - - previous_version = previous_eap.version if previous_eap else None + partner_ns_emails = list(latest_eap.partner_contacts.values_list("email", flat=True)) regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) @@ -409,7 +388,7 @@ def send_feedback_email_for_resubmitted_eap(eap_registration_id: int): email_context = get_eap_email_context(instance) email_subject = ( f"[DREF {instance.get_eap_type_display()} FEEDBACK] " - f"{instance.country} {instance.disaster_type} version {previous_version} TO {instance.national_society}" + f"{instance.country} {instance.disaster_type} version {latest_eap.version} TO {instance.national_society}" ) email_body = render_to_string("email/eap/feedback_to_revised_eap.html", email_context) email_type = "Feedback to the National Society" @@ -491,8 +470,7 @@ def send_pending_pfa_email(eap_registration_id: int): eap_registration_id=instance.id, ) - partner_contacts = latest_eap.partner_contacts - partner_ns_emails = list(partner_contacts.values_list("email", flat=True)) + partner_ns_emails = list(latest_eap.partner_contacts.values_list("email", flat=True)) regional_coordinator_emails: list[str] = get_coordinator_emails_by_region(instance.country.region) diff --git a/eap/test_views.py b/eap/test_views.py index 14554ba4b..6b45283e8 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -260,7 +260,7 @@ def test_active_eaps(self): partners=[self.partner2.id], created_by=self.country_admin, modified_by=self.country_admin, - status=EAPStatus.ACTIVATED, + status=EAPStatus.APPROVED, eap_type=EAPType.SIMPLIFIED_EAP, ) EAPRegistrationFactory.create( @@ -606,7 +606,6 @@ def test_create_simplified_eap(self): "planned_operations": [ { "sector": PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - "ap_code": 111, "people_targeted": 10000, "budget_per_sector": 100000, "indicators": [ @@ -650,7 +649,6 @@ def test_create_simplified_eap(self): ], "enabling_approaches": [ { - "ap_code": 11, "approach": EnablingApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, "indicators": [ @@ -716,7 +714,7 @@ def test_create_simplified_eap(self): # Cannot create Simplified EAP for the same EAP Registration again response = self.client.post(url, data, format="json") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400, response.data) def test_update_simplified_eap(self): eap_registration = EAPRegistrationFactory.create( @@ -773,7 +771,6 @@ def test_update_simplified_eap(self): enabling_approach = EnablingApproachFactory.create( approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, - ap_code=123, readiness_activities=[ enabling_approach_readiness_operation_activity_1.id, enabling_approach_readiness_operation_activity_2.id, @@ -830,7 +827,6 @@ def test_update_simplified_eap(self): # PLANNED OPERATION with activities planned_operation = PlannedOperationFactory.create( sector=PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - ap_code=456, people_targeted=5000, budget_per_sector=50000, readiness_activities=[ @@ -876,7 +872,6 @@ def test_update_simplified_eap(self): "id": enabling_approach.id, "approach": EnablingApproach.Approach.NATIONAL_SOCIETY_STRENGTHENING, "budget_per_approach": 8000, - "ap_code": 123, "readiness_activities": [ { "id": enabling_approach_readiness_operation_activity_1.id, @@ -906,7 +901,6 @@ def test_update_simplified_eap(self): { "approach": EnablingApproach.Approach.PARTNERSHIP_AND_COORDINATION, "budget_per_approach": 9000, - "ap_code": 124, "readiness_activities": [ { "activity": "New Enabling Approach Readiness Activity", @@ -943,7 +937,6 @@ def test_update_simplified_eap(self): { "id": planned_operation.id, "sector": PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - "ap_code": 456, "people_targeted": 8000, "budget_per_sector": 80000, "indicators": [ @@ -993,7 +986,6 @@ def test_update_simplified_eap(self): { # CREATE NEW Planned OperationActivity "sector": PlannedOperation.Sector.HEALTH_AND_CARE, - "ap_code": 457, "people_targeted": 6000, "budget_per_sector": 60000, "readiness_activities": [ @@ -1053,21 +1045,17 @@ def test_update_simplified_eap(self): response.data["enabling_approaches"][0]["id"], response.data["enabling_approaches"][0]["approach"], response.data["enabling_approaches"][0]["budget_per_approach"], - response.data["enabling_approaches"][0]["ap_code"], # NEW DATA response.data["enabling_approaches"][1]["approach"], response.data["enabling_approaches"][1]["budget_per_approach"], - response.data["enabling_approaches"][1]["ap_code"], }, { enabling_approach.id, data["enabling_approaches"][0]["approach"], data["enabling_approaches"][0]["budget_per_approach"], - data["enabling_approaches"][0]["ap_code"], # NEW DATA data["enabling_approaches"][1]["approach"], data["enabling_approaches"][1]["budget_per_approach"], - data["enabling_approaches"][1]["ap_code"], }, ) self.assertEqual( @@ -1125,24 +1113,20 @@ def test_update_simplified_eap(self): { response.data["planned_operations"][0]["id"], response.data["planned_operations"][0]["sector"], - response.data["planned_operations"][0]["ap_code"], response.data["planned_operations"][0]["people_targeted"], response.data["planned_operations"][0]["budget_per_sector"], # NEW DATA response.data["planned_operations"][1]["sector"], - response.data["planned_operations"][1]["ap_code"], response.data["planned_operations"][1]["people_targeted"], response.data["planned_operations"][1]["budget_per_sector"], }, { planned_operation.id, data["planned_operations"][0]["sector"], - data["planned_operations"][0]["ap_code"], data["planned_operations"][0]["people_targeted"], data["planned_operations"][0]["budget_per_sector"], # NEW DATA data["planned_operations"][1]["sector"], - data["planned_operations"][1]["ap_code"], data["planned_operations"][1]["people_targeted"], data["planned_operations"][1]["budget_per_sector"], }, @@ -1264,12 +1248,10 @@ def test_status_transition(self): enabling_approach = EnablingApproachFactory.create( approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, - ap_code=123, ) planned_operation = PlannedOperationFactory.create( sector=PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - ap_code=456, people_targeted=5000, budget_per_sector=50000, ) @@ -1292,6 +1274,8 @@ def test_status_transition(self): response = self.client.post(self.url, data, format="json") self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + simplified_eap.refresh_from_db() + self.assertTrue(simplified_eap.is_locked) # NOTE: Check if the NS can update after changing to UNDER_REVIEW # FAILS: As simplified EAP is in UNDER_REVIEW, cannot update @@ -1301,6 +1285,7 @@ def test_status_transition(self): "readiness_budget": 5000, "pre_positioning_budget": 5000, "early_action_budget": 5000, + "modified_at": datetime.now(), } url = f"/api/v2/simplified-eap/{simplified_eap.id}/" response = self.client.patch(url, update_data, format="json") @@ -1339,48 +1324,83 @@ def test_status_transition(self): self.eap_registration.latest_simplified_eap.review_checklist_file, ) - # NOTE: Check if snapshot is created or not - # First SimplifedEAP should be locked - simplified_eap.refresh_from_db() - self.assertTrue(simplified_eap.is_locked) + # Fails, As NS has to revise the EAP before transitioning to UNDER_REVIEW. + status_data = { + "status": EAPStatus.UNDER_REVIEW, + } + response = self.client.post(self.url, status_data) + self.assertEqual(response.status_code, 400, response.data) - # Two SimplifiedEAP should be there - eap_simplified_queryset = SimplifiedEAP.objects.filter( - eap_registration=self.eap_registration, - ) + # Fails, As User only can revise after NS_ADDRESSING_COMMENTS, cannot update directly as it's locked + update_data["modified_at"] = datetime.now() + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400, response.data) - self.assertEqual( - eap_simplified_queryset.count(), - 2, - "There should be two snapshots created.", - ) + # NOTE: NS revise which creates a snapshot of simplified eap + # updates the latest simplified eap with updated checklist file. So, there should be two snapshots of SimplifiedEAP now. + revise_url = f"/api/v2/simplified-eap/{simplified_eap.id}/revise/" + self.authenticate(self.country_admin) + response = self.client.post(revise_url) + self.assertEqual(response.status_code, 200, response.data) + self.assertFalse(response.data["is_locked"], response.data) - # Check version of the latest snapshot - # Version should be 2 - second_snapshot = eap_simplified_queryset.order_by("-version").first() - assert second_snapshot is not None, "Second snapshot should not be None." + # NOTE: Check if snapshot is created or not + # First SimplifedEAP should be locked + self.eap_registration.refresh_from_db() + simplified_eap.refresh_from_db() + self.assertTrue(simplified_eap.is_locked) - self.assertEqual( - second_snapshot.version, - 2, - "Latest snapshot version should be 2.", - ) - # Check for parent_id - self.assertEqual( - second_snapshot.parent_id, - simplified_eap.id, - "Latest snapshot's parent_id should be the first SimplifiedEAP id.", - ) - # Snapshot Shouldn't have the updated checklist file - self.assertFalse( - second_snapshot.updated_checklist_file, - "Latest Snapshot shouldn't have the updated checklist file.", - ) - # Check if the latest_simplified_eap is updated in EAPRegistration - self.assertEqual( - self.eap_registration.latest_simplified_eap.id, - second_snapshot.id, - ) + # Two SimplifiedEAP should be there + eap_simplified_queryset = SimplifiedEAP.objects.filter( + eap_registration=self.eap_registration, + ) + + self.assertEqual( + eap_simplified_queryset.count(), + 2, + "There should be two snapshots created.", + ) + + # Check version of the latest snapshot + # Version should be 2 + second_snapshot = eap_simplified_queryset.order_by("-version").first() + assert second_snapshot is not None, "Second snapshot should not be None." + + self.assertEqual( + second_snapshot.version, + 2, + "Latest snapshot version should be 2.", + ) + # Check for parent_id + self.assertEqual( + second_snapshot.parent_id, + simplified_eap.id, + "Latest snapshot's parent_id should be the first SimplifiedEAP id.", + ) + # Snapshot Shouldn't have the updated checklist file + self.assertFalse( + second_snapshot.updated_checklist_file, + "Latest Snapshot shouldn't have the updated checklist file.", + ) + # Check if the latest_simplified_eap is updated in EAPRegistration + self.assertEqual( + self.eap_registration.latest_simplified_eap.id, + second_snapshot.id, + ) + # Second snapshot should not be locked. + self.assertFalse(second_snapshot.is_locked, "Latest snapshot shouldn't be locked."), + + # NOTE: Cannot create revise as this eap has already been revised once. + revise_url = f"/api/v2/simplified-eap/{simplified_eap.id}/revise/" + self.authenticate(self.country_admin) + response = self.client.post(revise_url) + self.assertEqual(response.status_code, 400, response.data) + + # NOTE: Cannot update in previous snapshot + url = f"/api/v2/simplified-eap/{simplified_eap.id}/" + update_data["modified_at"] = datetime.now() + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400, response.data) # NOTE: Transition to UNDER_REVIEW # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW @@ -1415,6 +1435,8 @@ def test_status_transition(self): response = self.client.post(self.url, data, format="json") self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.UNDER_REVIEW) + second_snapshot.refresh_from_db() + self.assertTrue(second_snapshot.is_locked, "Should be locked after transition to UNDER_REVIEW.") # AGAIN NOTE: Transition to NS_ADDRESSING_COMMENTS # UNDER_REVIEW -> NS_ADDRESSING_COMMENTS @@ -1437,48 +1459,79 @@ def test_status_transition(self): self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) - # Check if three snapshots are created now - eap_simplified_queryset = SimplifiedEAP.objects.filter( - eap_registration=self.eap_registration, - ) - self.assertEqual( - eap_simplified_queryset.count(), - 3, - "There should be three snapshots created.", - ) + # Fails, As NS has to revise the EAP before transitioning to UNDER_REVIEW. + status_data = { + "status": EAPStatus.UNDER_REVIEW, + } + response = self.client.post(self.url, status_data) + self.assertEqual(response.status_code, 400, response.data) - # Check version of the latest snapshot - # Version should be 3 - third_snapshot = eap_simplified_queryset.order_by("-version").first() - assert third_snapshot is not None, "Third snapshot should not be None." + # Cannot update as it is in NS_ADDRESSING_COMMENTS, only revise is allowed + update_data["modified_at"] = datetime.now() + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400, response.data) - self.assertEqual( - third_snapshot.version, - 3, - "Latest snapshot version should be 3.", - ) - # Check for parent_id - self.assertEqual( - third_snapshot.parent_id, - second_snapshot.id, - "Latest snapshot's parent_id should be the second Snapshot id.", - ) + revise_url = f"/api/v2/simplified-eap/{second_snapshot.id}/revise/" + self.authenticate(self.country_admin) + response = self.client.post(revise_url) + self.assertEqual(response.status_code, 200, response.data) + self.assertFalse(response.data["is_locked"]) - # Check if the second snapshot is locked. - second_snapshot.refresh_from_db() - self.assertTrue(second_snapshot.is_locked) - # Snapshot Shouldn't have the updated checklist file - self.assertFalse( - third_snapshot.updated_checklist_file, - "Latest snapshot shouldn't have the updated checklist file.", - ) + # Check if three snapshots are created now + self.eap_registration.refresh_from_db() + eap_simplified_queryset = SimplifiedEAP.objects.filter( + eap_registration=self.eap_registration, + ) + self.assertEqual( + eap_simplified_queryset.count(), + 3, + "There should be three snapshots created.", + ) - # Check if the latest_simplified_eap is updated in EAPRegistration - self.eap_registration.refresh_from_db() - self.assertEqual( - self.eap_registration.latest_simplified_eap.id, - third_snapshot.id, - ) + # Check version of the latest snapshot + # Version should be 3 + third_snapshot = eap_simplified_queryset.order_by("-version").first() + assert third_snapshot is not None, "Third snapshot should not be None." + + self.assertEqual( + third_snapshot.version, + 3, + "Latest snapshot version should be 3.", + ) + # Check for parent_id + self.assertEqual( + third_snapshot.parent_id, + second_snapshot.id, + "Latest snapshot's parent_id should be the second Snapshot id.", + ) + + # Check if the second snapshot is locked. + second_snapshot.refresh_from_db() + self.assertTrue(second_snapshot.is_locked) + # Snapshot Shouldn't have the updated checklist file + self.assertFalse( + third_snapshot.updated_checklist_file, + "Latest snapshot shouldn't have the updated checklist file.", + ) + + # Check if the latest_simplified_eap is updated in EAPRegistration + self.eap_registration.refresh_from_db() + self.assertEqual( + self.eap_registration.latest_simplified_eap.id, + third_snapshot.id, + ) + + # NOTE: Cannot create another snapshot as this eap has already been revised + revise_url = f"/api/v2/simplified-eap/{second_snapshot.id}/revise/" + self.authenticate(self.country_admin) + response = self.client.post(revise_url) + self.assertEqual(response.status_code, 400, response.data) + + # NOTE: Cannot update the previous snapshot + url = f"/api/v2/simplified-eap/{second_snapshot.id}/" + update_data["modified_at"] = datetime.now() + response = self.client.patch(url, update_data, format="json") + self.assertEqual(response.status_code, 400, response.data) # NOTE: Again Transition to UNDER_REVIEW # NS_ADDRESSING_COMMENTS -> UNDER_REVIEW @@ -1548,49 +1601,67 @@ def test_status_transition(self): self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) - # Check if four snapshots are created now - self.eap_registration.refresh_from_db() - eap_simplified_queryset = SimplifiedEAP.objects.filter( - eap_registration=self.eap_registration, - ) - self.assertEqual( - eap_simplified_queryset.count(), - 4, - "There should be four snapshots created.", - ) + # Fails, As NS has to revise the EAP before transitioning to UNDER_REVIEW. + status_data = { + "status": EAPStatus.UNDER_REVIEW, + } + response = self.client.post(self.url, status_data) + self.assertEqual(response.status_code, 400, response.data) - # Check version of the latest snapshot - # Version should be 4 - fourth_snapshot = eap_simplified_queryset.order_by("-version").first() - assert fourth_snapshot is not None, "fourth snapshot should not be None." + revise_url = f"/api/v2/simplified-eap/{third_snapshot.id}/revise/" + self.authenticate(self.country_admin) + response = self.client.post(revise_url) + self.assertEqual(response.status_code, 200) - self.assertEqual( - fourth_snapshot.version, - 4, - "Latest snapshot version should be 4.", - ) - # Check for parent_id - self.assertEqual( - fourth_snapshot.parent_id, - third_snapshot.id, - "Latest snapshot's parent_id should be the third Snapshot id.", - ) + # Check if four snapshots are created now + self.eap_registration.refresh_from_db() + eap_simplified_queryset = SimplifiedEAP.objects.filter( + eap_registration=self.eap_registration, + ) + self.assertEqual( + eap_simplified_queryset.count(), + 4, + "There should be four snapshots created.", + ) - # Check if the second snapshot is locked. - third_snapshot.refresh_from_db() - self.assertTrue(third_snapshot.is_locked) - # Snapshot Shouldn't have the updated checklist file - self.assertFalse( - fourth_snapshot.updated_checklist_file, - "Latest snapshot shouldn't have the updated checklist file.", - ) + # Check version of the latest snapshot + # Version should be 4 + fourth_snapshot = eap_simplified_queryset.order_by("-version").first() + assert fourth_snapshot is not None, "fourth snapshot should not be None." - # Check if the latest_simplified_eap is updated in EAPRegistration - self.eap_registration.refresh_from_db() - self.assertEqual( - self.eap_registration.latest_simplified_eap.id, - fourth_snapshot.id, - ) + self.assertEqual( + fourth_snapshot.version, + 4, + "Latest snapshot version should be 4.", + ) + # Check for parent_id + self.assertEqual( + fourth_snapshot.parent_id, + third_snapshot.id, + "Latest snapshot's parent_id should be the third Snapshot id.", + ) + + # Check if the second snapshot is locked. + third_snapshot.refresh_from_db() + self.assertTrue(third_snapshot.is_locked) + # Snapshot Shouldn't have the updated checklist file + self.assertFalse( + fourth_snapshot.updated_checklist_file, + "Latest snapshot shouldn't have the updated checklist file.", + ) + + # Check if the latest_simplified_eap is updated in EAPRegistration + self.eap_registration.refresh_from_db() + self.assertEqual( + self.eap_registration.latest_simplified_eap.id, + fourth_snapshot.id, + ) + + # NOTE: Cannot create another snapshot from locked snapshot + revise_url = f"/api/v2/simplified-eap/{third_snapshot.id}/revise/" + self.authenticate(self.country_admin) + response = self.client.post(revise_url) + self.assertEqual(response.status_code, 400, response.data) # NOTE: NS Updates the latest changes on the fourth snapshot and update checklist file @@ -1687,7 +1758,7 @@ def test_status_transition(self): # FAILS As simplified EAP is in PENDING_PFA, cannot updated url = f"/api/v2/simplified-eap/{simplified_eap.id}/" response = self.client.patch(url, update_data, format="json") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400, response.data) # NOTE: Transition to APPROVED # PENDING_PFA -> APPROVED @@ -1717,37 +1788,7 @@ def test_status_transition(self): self.authenticate(self.country_admin) url = f"/api/v2/simplified-eap/{simplified_eap.id}/" response = self.client.patch(url, update_data, format="json") - self.assertEqual(response.status_code, 400) - - # NOTE: Transition to ACTIVATED - # APPROVED -> ACTIVATED - data = { - "status": EAPStatus.ACTIVATED, - } - - # LOGIN as country admin user - # FAILS: As only ifrc admins or superuser can - self.authenticate(self.country_admin) - response = self.client.post(self.url, data, format="json") - self.assertEqual(response.status_code, 400) - - # LOGIN as IFRC admin user - # SUCCESS: As only ifrc admins or superuser can - self.assertIsNone(self.eap_registration.activated_at) - self.authenticate(self.ifrc_admin_user) - response = self.client.post(self.url, data, format="json") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["status"], EAPStatus.ACTIVATED) - # Check is the activated timeline is added - self.eap_registration.refresh_from_db() - self.assertIsNotNone(self.eap_registration.activated_at) - - # Check as if NS user cannot update after ACTIVATED - # FAILS As simplified EAP is in ACTIVATED, cannot updated - self.authenticate(self.country_admin) - url = f"/api/v2/simplified-eap/{simplified_eap.id}/" - response = self.client.patch(url, update_data, format="json") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 400, response.data) @mock.patch("eap.serializers.generate_export_eap_pdf") @mock.patch("eap.serializers.group") @@ -1802,12 +1843,10 @@ def test_status_transitions_trigger_email( enabling_approach = EnablingApproachFactory.create( approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, - ap_code=123, ) planned_operation = PlannedOperationFactory.create( sector=PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - ap_code=456, people_targeted=5000, budget_per_sector=50000, ) @@ -1865,11 +1904,19 @@ def test_status_transitions_trigger_email( self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) send_feedback_email.delay.assert_called_once_with(eap_registration.id) send_feedback_email.delay.reset_mock() + + # Revise the eap which create a snapshot. + revise_url = f"/api/v2/simplified-eap/{simplified_eap.id}/revise/" + self.authenticate(self.ifrc_admin_user) + response = self.client.post(revise_url) + self.assert_200(response) + # ----------------------------- # Check snapshots after the status change # ----------------------------- snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() assert snapshot is not None, "Snapshot should exist now" + assert snapshot.version == 2, "Snapshot version should be 2" eap_registration.latest_simplified_eap = snapshot eap_registration.save() @@ -1930,11 +1977,19 @@ def test_status_transitions_trigger_email( self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id) send_feedback_email_for_resubmitted_eap.delay.reset_mock() + + # Revise the eap which create a snapshot. + revise_url = f"/api/v2/simplified-eap/{snapshot.id}/revise/" + self.authenticate(self.ifrc_admin_user) + response = self.client.post(revise_url) + self.assert_200(response) + # ----------------------------- # Check snapshots after the status change # ----------------------------- snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() assert snapshot is not None, "Snapshot should exist now" + assert snapshot.version == 3, "Snapshot version should be 3" eap_registration.latest_simplified_eap = snapshot eap_registration.save() @@ -1999,11 +2054,19 @@ def test_status_transitions_trigger_email( self.assertEqual(response.data["status"], EAPStatus.NS_ADDRESSING_COMMENTS) send_feedback_email_for_resubmitted_eap.delay.assert_called_once_with(eap_registration.id) send_feedback_email_for_resubmitted_eap.delay.reset_mock() + + # Revise the eap which create a snapshot. + revise_url = f"/api/v2/simplified-eap/{snapshot.id}/revise/" + self.authenticate(self.ifrc_admin_user) + response = self.client.post(revise_url) + self.assert_200(response) + # ----------------------------- # Check snapshots after the status change # ----------------------------- snapshot = SimplifiedEAP.objects.filter(eap_registration=eap_registration).order_by("-version").first() assert snapshot is not None, "Snapshot should exist now" + assert snapshot.version == 4, "Snapshot version should be 4" simplified_eap.refresh_from_db() eap_registration.latest_simplified_eap = snapshot eap_registration.save() @@ -2427,6 +2490,7 @@ def test_create_full_eap(self): "total_budget": 10000, "objective": "FUll eap objective", "lead_time": 5, + "lead_timeframe_unit": TimeFrame.DAYS, "expected_submission_time": "2024-12-31", "readiness_budget": 3000, "pre_positioning_budget": 4000, @@ -2475,7 +2539,6 @@ def test_create_full_eap(self): "planned_operations": [ { "sector": PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - "ap_code": 111, "people_targeted": 10000, "budget_per_sector": 100000, "indicators": [ @@ -2519,7 +2582,6 @@ def test_create_full_eap(self): ], "enabling_approaches": [ { - "ap_code": 11, "approach": EnablingApproach.Approach.SECRETARIAT_SERVICES, "budget_per_approach": 10000, "indicators": [ @@ -2668,7 +2730,6 @@ def test_snapshot_full_eap(self): enabling_approach = EnablingApproachFactory.create( approach=EnablingApproach.Approach.SECRETARIAT_SERVICES, budget_per_approach=5000, - ap_code=123, ) hazard_selection_image_1 = EAPFileFactory._create_file( created_by=self.user, @@ -2690,7 +2751,6 @@ def test_snapshot_full_eap(self): planned_operation = PlannedOperationFactory.create( sector=PlannedOperation.Sector.SHELTER_SETTLEMENT_AND_HOUSING, - ap_code=456, people_targeted=5000, budget_per_sector=50000, readiness_activities=[ diff --git a/eap/utils.py b/eap/utils.py index 5fc5c1e45..58e6d6992 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -10,7 +10,7 @@ from rest_framework import serializers from api.models import Region, RegionName -from eap.models import EAPType, FullEAP, SimplifiedEAP +from eap.models import EAPType REGION_EMAIL_MAP: dict[RegionName, list[str]] = { RegionName.AFRICA: settings.EMAIL_EAP_AFRICA_COORDINATORS, @@ -114,23 +114,8 @@ def get_eap_email_context(instance): if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: latest_eap_data = instance.latest_simplified_eap - eap_model = SimplifiedEAP else: latest_eap_data = instance.latest_full_eap - eap_model = FullEAP - - latest_version = latest_eap_data.version - - previous_eap = ( - eap_model.objects.filter( - eap_registration=instance, - version__lt=latest_version, - ) - .order_by("-version") - .first() - ) - - previous_version = previous_eap.version if previous_eap else None email_context.update( { @@ -138,7 +123,6 @@ def get_eap_email_context(instance): "people_targeted": latest_eap_data.people_targeted, "total_budget": latest_eap_data.total_budget, "latest_version": latest_eap_data.version, - "previous_version": previous_version, "export_file": (latest_eap_data.export_file.url if latest_eap_data.export_file else None), "diff_file": (latest_eap_data.diff_file.url if latest_eap_data.diff_file else None), "budget_file": get_file_url(latest_eap_data.budget_file), diff --git a/eap/views.py b/eap/views.py index a4ee97326..d5427ec1b 100644 --- a/eap/views.py +++ b/eap/views.py @@ -5,6 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import mixins, permissions, response, status, viewsets from rest_framework.decorators import action +from rest_framework.generics import GenericAPIView from eap.filter_set import ( EAPRegistrationFilterSet, @@ -32,6 +33,7 @@ EAPFileInputSerializer, EAPFileSerializer, EAPGlobalFilesSerializer, + EAPOptionsSerializer, EAPRegistrationSerializer, EAPShareUserSerializer, EAPStatusSerializer, @@ -64,7 +66,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: return ( super() .get_queryset() - .filter(status__in=[EAPStatus.APPROVED, EAPStatus.ACTIVATED]) + .filter(status=EAPStatus.APPROVED) .select_related("disaster_type", "country") .annotate( requirement_cost=Case( @@ -106,6 +108,7 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: ) .prefetch_related( "partners", + "users", Prefetch( "simplified_eaps", queryset=SimplifiedEAP.objects.select_related( @@ -120,6 +123,12 @@ def get_queryset(self) -> QuerySet[EAPRegistration]: queryset=FullEAP.objects.select_related( "budget_file__created_by", "budget_file__modified_by", + "updated_checklist_file__created_by", + "updated_checklist_file__modified_by", + "theory_of_change_table_file__created_by", + "theory_of_change_table_file__modified_by", + "forecast_table_file__created_by", + "forecast_table_file__modified_by", ), ), ) @@ -238,6 +247,7 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: ) .prefetch_related( "eap_registration__partners", + "partners", "partner_contacts", "admin2", Prefetch( @@ -273,6 +283,52 @@ def get_queryset(self) -> QuerySet[SimplifiedEAP]: ) ) + @extend_schema( + request=None, + ) + @action( + detail=True, + url_path="revise", + methods=["post"], + serializer_class=SimplifiedEAPSerializer, + permission_classes=[ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPBasePermission, + ], + ) + def revise( + self, + request, + id: int, + ): + simplified_eap_instance = self.get_object() + if simplified_eap_instance.eap_registration.status != EAPStatus.NS_ADDRESSING_COMMENTS: + return response.Response( + {"detail": f"Only EAPs with status {EAPStatus.NS_ADDRESSING_COMMENTS} can be revised."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if simplified_eap_instance.is_locked is False: + return response.Response( + {"detail": "EAP can only be revised when it is locked."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if new version of EAP can be created (i.e. if the current version is locked) + eap_registration_instance = EAPRegistration.objects.filter(id=simplified_eap_instance.eap_registration_id).first() + if eap_registration_instance and eap_registration_instance.latest_simplified_eap_id != simplified_eap_instance.id: + return response.Response( + {"detail": "A new version of the EAP has already been created. Please revise the latest version of the EAP."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + snapshot_instance = simplified_eap_instance.generate_snapshot() + simplified_eap_instance.eap_registration.latest_simplified_eap = snapshot_instance + simplified_eap_instance.eap_registration.save(update_fields=["latest_simplified_eap"]) + serializer = SimplifiedEAPSerializer(snapshot_instance, context={"request": request}) + return response.Response(serializer.data, status=status.HTTP_200_OK) + class FullEAPViewSet(EAPModelViewSet): queryset = FullEAP.objects.all() @@ -296,6 +352,7 @@ def get_queryset(self) -> QuerySet[FullEAP]: ) .prefetch_related( "admin2", + "partners", "partner_contacts", "prioritized_impacts", "early_actions", @@ -348,6 +405,52 @@ def get_queryset(self) -> QuerySet[FullEAP]: ) ) + @extend_schema( + request=None, + ) + @action( + detail=True, + url_path="revise", + methods=["post"], + serializer_class=FullEAPSerializer, + permission_classes=[ + permissions.IsAuthenticated, + DenyGuestUserMutationPermission, + EAPBasePermission, + ], + ) + def revise( + self, + request, + id: int, + ): + full_eap_instance = self.get_object() + if full_eap_instance.eap_registration.status != EAPStatus.NS_ADDRESSING_COMMENTS: + return response.Response( + {"detail": f"Only EAPs with status {EAPStatus.NS_ADDRESSING_COMMENTS} can be revised."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if full_eap_instance.is_locked is False: + return response.Response( + {"detail": "EAP can only be revised when it is locked."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if new version of EAP can be created (i.e. if the current version is locked) + eap_registration_instance = EAPRegistration.objects.filter(id=full_eap_instance.eap_registration_id).first() + if eap_registration_instance and eap_registration_instance.latest_full_eap_id != full_eap_instance.id: + return response.Response( + {"detail": "A new version of the EAP has already been created. Please revise the latest version of the EAP."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + snapshot_instance = full_eap_instance.generate_snapshot() + full_eap_instance.eap_registration.latest_full_eap = snapshot_instance + full_eap_instance.eap_registration.save(update_fields=["latest_full_eap"]) + serializer = FullEAPSerializer(snapshot_instance, context={"request": request}) + return response.Response(serializer.data, status=status.HTTP_200_OK) + class EAPFileViewSet( viewsets.GenericViewSet, @@ -435,3 +538,15 @@ def retrieve(self, request, *args, **kwargs): ) serializer = EAPGlobalFilesSerializer({"url": request.build_absolute_uri(static(self.template_map[template_type]))}) return response.Response(serializer.data) + + +class EAPOptionsView(GenericAPIView): + permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] + serializer_class = EAPOptionsSerializer + + def get(self, request, *args, **kwargs): + data = { + "sector_ap_codes": PlannedOperation.Sector.get_sector_ap_codes(), + "approach_ap_codes": EnablingApproach.Approach.get_approach_ap_codes(), + } + return response.Response(self.get_serializer(data).data) diff --git a/main/urls.py b/main/urls.py index b6b2f2ca7..bb08d916e 100644 --- a/main/urls.py +++ b/main/urls.py @@ -222,6 +222,8 @@ url(r"^api/v2/brief", Brief.as_view()), url(r"^api/v2/erutype", ERUTypes.as_view()), url(r"^api/v2/export-eru-readiness", deployment_views.ExportERUReadinessView.as_view()), + # EAP + url(r"^api/v2/eap/options/", eap_views.EAPOptionsView.as_view()), url(r"^api/v2/recentaffected", RecentAffecteds.as_view()), url(r"^api/v2/fieldreportstatus", FieldReportStatuses.as_view()), url(r"^api/v2/primarysector", ProjectPrimarySectors.as_view()), diff --git a/notifications/templates/email/eap/feedback_to_revised_eap.html b/notifications/templates/email/eap/feedback_to_revised_eap.html index a74569826..fac7fe014 100644 --- a/notifications/templates/email/eap/feedback_to_revised_eap.html +++ b/notifications/templates/email/eap/feedback_to_revised_eap.html @@ -11,7 +11,7 @@

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