diff --git a/api/admin.py b/api/admin.py index 01352ed76..414befee6 100644 --- a/api/admin.py +++ b/api/admin.py @@ -8,7 +8,6 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import Permission, User from django.contrib.gis import admin as geoadmin -from django.core.exceptions import ValidationError from django.db.models import OuterRef, Subquery, Value from django.db.models.functions import Concat from django.http import HttpResponse, HttpResponseRedirect @@ -185,7 +184,55 @@ class EventLinkInline(admin.TabularInline, TranslationInlineModelAdmin): model = models.EventLink +class EventAdminForm(forms.ModelForm): + class Meta: + model = models.Event + fields = "__all__" + + def clean(self): + cleaned_data = super().clean() + if self.instance.pk is None: + return cleaned_data + + new_severity = cleaned_data.get("ifrc_severity_level") + new_update_date = cleaned_data.get("ifrc_severity_level_update_date") + original_severity = self.instance.ifrc_severity_level + original_update_date = self.instance.ifrc_severity_level_update_date + + if original_update_date is not None and new_update_date is None: + self.add_error( + "ifrc_severity_level_update_date", + "This field cannot be cleared once it has been set.", + ) + return cleaned_data + + severity_changed = original_severity != new_severity + update_date_changed = original_update_date != new_update_date + + if severity_changed and not update_date_changed: + self.add_error( + "ifrc_severity_level_update_date", + "You must update this field when changing the severity level.", + ) + return cleaned_data + + if ( + severity_changed + and update_date_changed + and original_update_date is not None + and new_update_date is not None + and original_update_date > new_update_date + ): + self.add_error( + "ifrc_severity_level_update_date", + "This date can not be earlier than the previous one.", + ) + + return cleaned_data + + class EventAdmin(CompareVersionAdmin, RegionRestrictedAdmin, TranslationAdmin): + form = EventAdminForm @admin.display(ordering="ifrc_severity_level_update_date") def level_updated_at(self, obj): @@ -405,19 +452,8 @@ def save_model(self, request, obj, form, change): severity_changed = original.ifrc_severity_level != obj.ifrc_severity_level update_date_changed = original.ifrc_severity_level_update_date != obj.ifrc_severity_level_update_date - if severity_changed and not update_date_changed: - messages.error( - request, "You must update the 'IFRC Severity Level Update Date/Time' when changing the severity level." - ) - raise ValidationError("Cannot change severity level without updating the update date/time.") - + # Validation for these fields is handled in EventAdminForm.clean(). if severity_changed and update_date_changed: - if ( - original.ifrc_severity_level_update_date is not None - and original.ifrc_severity_level_update_date > obj.ifrc_severity_level_update_date - ): - messages.error(request, "A severity level update date can not be earlier than the previous one.") - raise ValidationError("A severity level update date can not be earlier than the previous one.") models.EventSeverityLevelHistory.objects.create( event=obj, ifrc_severity_level=original.ifrc_severity_level, diff --git a/api/drf_views.py b/api/drf_views.py index 85c51dd47..cdf9bfe50 100644 --- a/api/drf_views.py +++ b/api/drf_views.py @@ -730,6 +730,33 @@ def get_queryset(self): ) +class EventOrderingFilter(NullsLastOrderingFilter): + """ + Appends a deterministic (-disaster_start_date, id) fallback to whatever ordering was + requested, so paginated /api/v2/event/ results stay stable even when the requested + column is empty or duplicated across rows. + """ + + fallback_ordering_fields = ("-disaster_start_date", "id") + + def get_ordering(self, request, queryset, view): + # Bypass NullsLastOrderingFilter.get_ordering to get the raw field names, so the + # fallback fields can be appended before nulls-last is applied to all of them. + ordering = filters.OrderingFilter.get_ordering(self, request, queryset, view) + ordering = list(ordering) if ordering else [] + + existing_fields = {field.lstrip("-") for field in ordering} + ordering += [field for field in self.fallback_ordering_fields if field.lstrip("-") not in existing_fields] + + nulls_last_ordering = [] + for field in ordering: + if field.startswith("-"): + nulls_last_ordering.append(F(field[1:]).desc(nulls_last=True)) + else: + nulls_last_ordering.append(F(field).asc(nulls_last=True)) + return nulls_last_ordering + + class EventViewset(ReadOnlyVisibilityViewset): ordering_fields = ( "disaster_start_date", @@ -741,6 +768,7 @@ class EventViewset(ReadOnlyVisibilityViewset): "glide", "ifrc_severity_level", ) + filter_backends = (EventOrderingFilter, rest_filters.DjangoFilterBackend, filters.SearchFilter) filterset_class = EventFilter visibility_model_class = Event search_fields = ( diff --git a/api/serializers.py b/api/serializers.py index 61cc5dc1e..f711c2770 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2982,6 +2982,7 @@ def _get_stage_instance(self, event): "proposed_action", queryset=ProposedAction.objects.prefetch_related("activities"), ), + "needs_identified", Prefetch( "drefoperationalupdate_set", queryset=DrefOperationalUpdate.objects.filter( @@ -2998,14 +2999,14 @@ def get_stage_display(self, event): stage = getattr(event, "stage", None) return EventStage(stage).label if stage is not None else None - @extend_schema_field(EmergencyFieldReportSerializer()) + @extend_schema_field(EmergencyFieldReportSerializer(allow_null=True)) def get_field_report(self, event): if getattr(event, "stage", None) != EventStage.FIELD_REPORT: return None instance = self._get_stage_instance(event) return EmergencyFieldReportSerializer(instance, context=self.context).data if instance else None - @extend_schema_field(RelatedAppealSerializer()) + @extend_schema_field(RelatedAppealSerializer(allow_null=True)) def get_appeal(self, event): if getattr(event, "stage", None) not in ( EventStage.EMERGENCY_APPEAL, @@ -3015,7 +3016,7 @@ def get_appeal(self, event): instance = self._get_stage_instance(event) return RelatedAppealSerializer(instance, context=self.context).data if instance else None - @extend_schema_field(EmergencyDrefSerializer()) + @extend_schema_field(EmergencyDrefSerializer(allow_null=True)) def get_dref(self, event): if getattr(event, "stage", None) not in ( EventStage.DREF_APPLICATION, diff --git a/api/test_models.py b/api/test_models.py index 4156042ab..dd22cddb5 100644 --- a/api/test_models.py +++ b/api/test_models.py @@ -1,13 +1,15 @@ +from datetime import timedelta from unittest.mock import patch from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import User +from django.forms.models import model_to_dict from django.test import RequestFactory, TestCase from django.utils import timezone from rest_framework.test import APITestCase import api.models as models -from api.admin import EventAdmin +from api.admin import EventAdmin, EventAdminForm from api.factories import country as countryFactory from api.factories import event as eventFactory from api.factories import field_report as fieldReportFactory @@ -178,3 +180,79 @@ def test_no_history_created_if_severity_level_not_changed(self): history = models.EventSeverityLevelHistory.objects.filter(event=self.event) self.assertEqual(history.count(), 0) + + +class EventAdminFormValidationTest(TestCase): + fixtures = ["DisasterTypes"] + + def setUp(self): + self.dtype = models.DisasterType.objects.get(pk=1) + self.event = eventFactory.EventFactory.create( + dtype=self.dtype, + ifrc_severity_level=models.AlertLevel.YELLOW, + ifrc_severity_level_update_date=timezone.now(), + ) + + def _form_data(self, **overrides): + data = model_to_dict(self.event) + data.update(overrides) + return data + + def test_severity_change_without_update_date_change_is_rejected(self): + form = EventAdminForm( + data=self._form_data(ifrc_severity_level=models.AlertLevel.RED), + instance=self.event, + ) + + self.assertFalse(form.is_valid()) + self.assertIn( + "You must update this field when changing the severity level.", + form.errors.get("ifrc_severity_level_update_date", []), + ) + + def test_clearing_an_already_set_update_date_is_rejected(self): + form = EventAdminForm( + data=self._form_data(ifrc_severity_level_update_date=""), + instance=self.event, + ) + + self.assertFalse(form.is_valid()) + self.assertIn( + "This field cannot be cleared once it has been set.", + form.errors.get("ifrc_severity_level_update_date", []), + ) + + def test_severity_change_with_earlier_update_date_is_rejected(self): + earlier_date = self.event.ifrc_severity_level_update_date - timedelta(days=1) + form = EventAdminForm( + data=self._form_data( + ifrc_severity_level=models.AlertLevel.RED, + ifrc_severity_level_update_date=earlier_date, + ), + instance=self.event, + ) + + self.assertFalse(form.is_valid()) + self.assertIn( + "This date can not be earlier than the previous one.", + form.errors.get("ifrc_severity_level_update_date", []), + ) + + def test_severity_change_with_valid_later_update_date_is_accepted(self): + new_date = self.event.ifrc_severity_level_update_date + timedelta(days=1) + form = EventAdminForm( + data=self._form_data( + ifrc_severity_level=models.AlertLevel.RED, + ifrc_severity_level_update_date=new_date, + ), + instance=self.event, + ) + + form.is_valid() + self.assertNotIn("ifrc_severity_level_update_date", form.errors) + + def test_severity_unchanged_does_not_require_update_date(self): + form = EventAdminForm(data=self._form_data(), instance=self.event) + + form.is_valid() + self.assertNotIn("ifrc_severity_level_update_date", form.errors) diff --git a/api/test_views.py b/api/test_views.py index a54da83a1..a64f8ba0b 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -32,6 +32,7 @@ DrefFinalReportFactory, DrefOperationalUpdateFactory, DrefSummaryFactory, + IdentifiedNeedFactory, ) from dref.models import Dref, DrefFile, DrefSummary from main.test_case import APITestCase @@ -406,6 +407,37 @@ def test_event_link_api(self): self.assertEqual(len(resp.json()["links"]), 5) +class EventOrderingTestCase(APITestCase): + """ + /api/v2/event/ must return a deterministic order even when the requested + ordering column is empty or duplicated across rows. + """ + + def test_requested_ordering_falls_back_to_disaster_start_date_and_id_on_ties(self): + # All 3 share num_affected=None, so the requested ordering alone can't + # disambiguate them; the (-disaster_start_date, id) fallback should. + e1 = EventFactory.create(num_affected=None, disaster_start_date="2024-01-01", parent_event=None) + e2 = EventFactory.create(num_affected=None, disaster_start_date="2024-03-01", parent_event=None) + e3 = EventFactory.create(num_affected=None, disaster_start_date="2024-02-01", parent_event=None) + + response = self.client.get("/api/v2/event/?ordering=num_affected&limit=10") + self.assertEqual(response.status_code, 200) + + ids = [item["id"] for item in response.data["results"]] + relevant_ids = [i for i in ids if i in {e1.id, e2.id, e3.id}] + self.assertEqual(relevant_ids, [e2.id, e3.id, e1.id]) + + def test_ordering_is_deterministic_without_an_explicit_ordering_param(self): + e1 = EventFactory.create(disaster_start_date="2024-01-01", parent_event=None) + e2 = EventFactory.create(disaster_start_date="2024-03-01", parent_event=None) + + response = self.client.get("/api/v2/event/?limit=10") + self.assertEqual(response.status_code, 200) + + ids = [item["id"] for item in response.data["results"]] + self.assertLess(ids.index(e2.id), ids.index(e1.id)) + + class SituationReportTypeTest(APITestCase): fixtures = ["DisasterTypes"] @@ -1303,6 +1335,36 @@ def test_stage_dref_final_report(self): self.assertIsNone(data["field_report"]) self.assertIsNone(data["appeal"]) + def test_dref_needs_identified_is_exposed_on_application_and_final_report(self): + event = EventFactory.create(dtype=self.disaster_type) + need = IdentifiedNeedFactory.create() + dref = DrefFactory.create( + event=event, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + needs_identified=[need], + ) + + data = self._get(event).data + self.assertEqual([n["id"] for n in data["dref"]["needs_identified"]], [need.id]) + + final_report_need = IdentifiedNeedFactory.create() + final_report = DrefFinalReportFactory.create( + dref=dref, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + needs_identified=[final_report_need], + ) + + data = self._get(event).data + self.assertEqual(data["dref"]["final_report_details"]["id"], final_report.id) + self.assertEqual( + [n["id"] for n in data["dref"]["final_report_details"]["needs_identified"]], + [final_report_need.id], + ) + def test_stage_emergency_appeal(self): event = EventFactory.create( dtype=self.disaster_type, @@ -1561,6 +1623,9 @@ def test_dref_summary_fields_present_when_summary_exists(self): self.assertEqual(summary["people_centered_approach"], "approach text") self.assertEqual(summary["challenges_identified"], "challenges text") self.assertEqual(summary["lessons_learned"], "lessons text") + self.assertEqual(summary["source"], DrefSummary.SourceModel.DREF) + self.assertEqual(summary["source_display"], DrefSummary.SourceModel.DREF.label) + self.assertEqual(summary["source_id"], dref.id) def test_dref_summary_is_none_when_no_summary_exists(self): event = EventFactory.create(dtype=self.disaster_type) diff --git a/assets b/assets index 7c5b98dfc..420144863 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 7c5b98dfc773808c7a918040f386cc3b11929a2e +Subproject commit 42014486359345973f3632b245846ea49174485d diff --git a/dref/serializers.py b/dref/serializers.py index a055d710a..b016ac67d 100644 --- a/dref/serializers.py +++ b/dref/serializers.py @@ -2308,6 +2308,7 @@ class EmergencyDrefFinalReportSerializer(serializers.ModelSerializer): planned_interventions = PlannedInterventionSerializer(many=True, read_only=True) cover_image_file = DrefFileSerializer(source="cover_image", read_only=True) proposed_action = ProposedActionSerializer(many=True, required=False) + needs_identified = IdentifiedNeedSerializer(many=True, read_only=True) disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) dref_contacts = serializers.SerializerMethodField() @@ -2331,6 +2332,7 @@ class Meta: "district_details", "planned_interventions", "proposed_action", + "needs_identified", # Timeframe of operation "operation_start_date", "operation_end_date", @@ -2387,6 +2389,7 @@ class EmergencyDrefOperationalUpdateSerializer(serializers.ModelSerializer): planned_interventions = PlannedInterventionSerializer(many=True, read_only=True) cover_image_file = DrefFileSerializer(source="cover_image", required=False, allow_null=True) disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) + needs_identified = IdentifiedNeedSerializer(many=True, read_only=True) dref_contacts = serializers.SerializerMethodField() class Meta: @@ -2403,6 +2406,9 @@ class Meta: "event_scope", "event_description", "operational_update_number", + "update_date", + "summary_of_change", + "needs_identified", "event_date", "appeal_code", "glide_code", @@ -2436,6 +2442,7 @@ def get_dref_contacts(self, obj): class DrefSummarySerializer(ModelSerializer): status_display = serializers.CharField(source="get_status_display", read_only=True) + source_display = serializers.CharField(source="get_source_display", read_only=True) class Meta: model = DrefSummary @@ -2443,6 +2450,9 @@ class Meta: "id", "status", "status_display", + "source", + "source_display", + "source_id", "situational_overview", "operational_strategy", "people_centered_approach", @@ -2460,6 +2470,7 @@ class EmergencyDrefSerializer(serializers.ModelSerializer): district_details = MiniDistrictSerializer(source="district", read_only=True, many=True) planned_interventions = PlannedInterventionSerializer(many=True, read_only=True) proposed_action = ProposedActionSerializer(many=True, read_only=True) + needs_identified = IdentifiedNeedSerializer(many=True, read_only=True) cover_image_file = DrefFileSerializer(source="cover_image", required=False, allow_null=True) disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) type_of_dref_display = serializers.CharField(source="get_type_of_dref_display", read_only=True) @@ -2496,6 +2507,7 @@ class Meta: "district_details", "planned_interventions", "proposed_action", + "needs_identified", "emergency_appeal_planned", "government_requested_assistance", "did_ns_request_fund", @@ -2509,6 +2521,7 @@ class Meta: "men", "boys", "hazard_date_and_location", + "hazard_vulnerabilities_and_risks", "amount_requested", "total_cost", "total_targeted_population", diff --git a/dref/test_views.py b/dref/test_views.py index a0071523d..01f64331e 100644 --- a/dref/test_views.py +++ b/dref/test_views.py @@ -10,6 +10,7 @@ from django.core import management from rest_framework import status +from api.factories.event import EventFactory from api.models import Country, DisasterType, District, Event, Region, RegionName from api.utils import get_model_name from deployments.factories.project import SectorFactory @@ -1032,6 +1033,60 @@ def test_final_report_update_once_published(self): response = self.client.patch(patch_url, data) self.assert_400(response) + def test_final_report_approve_syncs_event_glide(self): + country = Country.objects.create(name="country-final-report-glide") + event = EventFactory.create(glide="OLD-GLIDE") + dref = DrefFactory.create( + title="Test Title", + created_by=self.root_user, + country=country, + event=event, + glide_code="OLD-GLIDE", + status=Dref.Status.APPROVED, + ) + final_report = DrefFinalReportFactory.create( + title="Test final report", + dref=dref, + country=country, + glide_code="NEW-GLIDE", + status=Dref.Status.FINALIZED, + ) + + self.client.force_authenticate(self.root_user) + approve_url = f"/api/v2/dref-final-report/{final_report.id}/approve/" + response = self.client.post(approve_url, {}) + self.assert_200(response) + + event.refresh_from_db() + self.assertEqual(event.glide, "NEW-GLIDE") + + def test_operational_update_approve_syncs_event_glide(self): + country = Country.objects.create(name="country-ops-update-glide") + event = EventFactory.create(glide="OLD-GLIDE") + dref = DrefFactory.create( + title="Test Title", + created_by=self.root_user, + country=country, + event=event, + glide_code="OLD-GLIDE", + status=Dref.Status.APPROVED, + ) + operational_update = DrefOperationalUpdateFactory.create( + title="Test operational update", + dref=dref, + country=country, + glide_code="UPDATED-GLIDE", + status=Dref.Status.FINALIZED, + ) + + self.client.force_authenticate(self.root_user) + approve_url = f"/api/v2/dref-op-update/{operational_update.id}/approve/" + response = self.client.post(approve_url, {}) + self.assert_200(response) + + event.refresh_from_db() + self.assertEqual(event.glide, "UPDATED-GLIDE") + def test_dref_for_assessment_report(self): old_count = Dref.objects.count() national_society = Country.objects.create(name="xzz") diff --git a/dref/utils.py b/dref/utils.py index 3baa13521..3399f15c4 100644 --- a/dref/utils.py +++ b/dref/utils.py @@ -85,3 +85,11 @@ def create_event_from_dref(dref: Dref) -> Event: if region: event.regions.add(region) return event + + +def sync_event_glide(event: Event, glide_code: str) -> None: + """Propagate a revision's (ops-update/final-report) glide code back to its event.""" + if not event or not glide_code or event.glide == glide_code: + return + event.glide = glide_code + event.save(update_fields=["glide"]) diff --git a/dref/views.py b/dref/views.py index 7dfe58e34..d91c34587 100644 --- a/dref/views.py +++ b/dref/views.py @@ -51,7 +51,7 @@ MiniDrefSerializer, ) from dref.tasks import generate_dref_summary, process_dref_translation -from dref.utils import create_event_from_dref +from dref.utils import create_event_from_dref, sync_event_glide from lang.serializers import TranslatedModelSerializerMixin from main.permissions import DenyGuestUserPermission @@ -234,6 +234,7 @@ def get_approved(self, request, pk=None, version=None): operational_update.status = Dref.Status.APPROVED operational_update.date_of_approval = timezone.now().date() operational_update.save(update_fields=["status", "date_of_approval"]) + sync_event_glide(operational_update.dref.event, operational_update.glide_code) transaction.on_commit(lambda: generate_dref_summary.delay(dref_id=operational_update.dref_id)) serializer = DrefOperationalUpdateSerializer(operational_update, context={"request": request}) return response.Response(serializer.data) @@ -299,10 +300,11 @@ def get_approved(self, request, pk=None, version=None): raise serializers.ValidationError(gettext("Must be finalized before it can be approved.")) final_report.status = Dref.Status.APPROVED - final_report.save(update_fields=["status"]) - final_report.dref.is_active = False final_report.date_of_approval = timezone.now().date() - final_report.dref.save(update_fields=["is_active", "date_of_approval"]) + final_report.save(update_fields=["status", "date_of_approval"]) + final_report.dref.is_active = False + final_report.dref.save(update_fields=["is_active"]) + sync_event_glide(final_report.dref.event, final_report.glide_code) transaction.on_commit(lambda: generate_dref_summary.delay(dref_id=final_report.dref_id)) serializer = DrefFinalReportSerializer(final_report, context={"request": request}) return response.Response(serializer.data)