Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions api/drf_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = (
Expand Down
7 changes: 4 additions & 3 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down
80 changes: 79 additions & 1 deletion api/test_models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
65 changes: 65 additions & 0 deletions api/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
DrefFinalReportFactory,
DrefOperationalUpdateFactory,
DrefSummaryFactory,
IdentifiedNeedFactory,
)
from dref.models import Dref, DrefFile, DrefSummary
from main.test_case import APITestCase
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion assets
Submodule assets updated 1 files
+83 −18 openapi-schema.yaml
Loading
Loading