diff --git a/api/admin.py b/api/admin.py index 0ec11e9fb..24b952aba 100644 --- a/api/admin.py +++ b/api/admin.py @@ -619,7 +619,7 @@ def create_events(self, request, queryset): dtype=getattr(report, "dtype"), disaster_start_date=getattr(report, "created_at"), auto_generated=True, - source=models.Event.EventSource.REPORT_ADMIN, + source=models.Event.EventSource.FIELD_REPORT_ADMIN, ) if getattr(report, "countries").exists(): for country in report.countries.all(): diff --git a/api/drf_views.py b/api/drf_views.py index 3fb9a838e..e2b0a8ce8 100644 --- a/api/drf_views.py +++ b/api/drf_views.py @@ -6,6 +6,7 @@ Avg, Case, Count, + Exists, ExpressionWrapper, F, OuterRef, @@ -13,6 +14,7 @@ Q, Subquery, Sum, + Value, When, ) from django.db.models.fields import IntegerField @@ -59,6 +61,7 @@ from databank.serializers import CountryOverviewSerializer from deployments.models import ERU, Personnel from deployments.serializers import ListDeployedERUByEventSerializer +from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate from main.enums import GlobalEnumSerializer, get_enum_values from main.filters import NullsLastOrderingFilter from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission @@ -73,6 +76,7 @@ Appeal, AppealDocument, AppealHistory, + AppealStatus, AppealType, Country, CountryKeyDocument, @@ -85,7 +89,9 @@ Event, EventContact, EventFeaturedDocument, + EventLink, EventSeverityLevelHistory, + EventStage, Export, ExternalPartner, FieldReport, @@ -749,11 +755,12 @@ def get_queryset(self, *args, **kwargs): qset = super().get_queryset() if self.action == "mini_events": # return Event.objects.filter(parent_event__isnull=True).select_related('dtype') - return qset.filter(parent_event__isnull=True).select_related("dtype") + return qset.filter(parent_event__isnull=True).select_related("dtype").prefetch_related("countries_for_preview") + if self.action == "response_activity_events": return ( qset.filter(parent_event__isnull=True) - .filter(Q(auto_generated=False) | Q(source=Event.EventSource.NEW_REPORT)) + .filter(Q(auto_generated=False) | Q(source=Event.EventSource.NEW_FIELD_REPORT)) .select_related("dtype") ) return ( @@ -869,7 +876,11 @@ def retrieve(self, request, pk=None, *args, **kwargs): ) @action(methods=["get"], detail=False, url_path="mini") def mini_events(self, request): - queryset = self.filter_queryset(self.get_queryset()) + queryset = self.filter_queryset(self.get_queryset()).annotate( + latest_field_report_id=Subquery( + FieldReport.objects.filter(event=OuterRef("pk")).order_by("-updated_at").values("id")[:1] + ) + ) serializer = ListMiniEventSerializer(queryset, many=True) page = self.paginate_queryset(queryset) if page is not None: @@ -1351,7 +1362,7 @@ class SupportedActivityViewset(viewsets.ReadOnlyModelViewSet): # summary=report.description or "", # disaster_start_date=report.start_date, # auto_generated=True, -# source=Event.EventSource.NEW_REPORT, +# source=Event.EventSource.NEW_FIELD_REPORT, # visibility=report.visibility, # **{TRANSLATOR_ORIGINAL_LANGUAGE_FIELD_NAME: django_get_language()}, # ) @@ -1559,45 +1570,208 @@ def get_queryset(self): return CountrySupportingPartner.objects.select_related("country") -class EmergencyViewset(ReadOnlyVisibilityViewset): +class EmergencyViewset( + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, + ReadOnlyVisibilityViewsetMixin, +): queryset = Event.objects.all() lookup_field = "id" serializer_class = DetailEmergencySerializer filterset_class = EventFilter - visibility_model_class = Event def get_queryset(self): + today = timezone.now().date() + + appeal_priority_qs = ( + Appeal.objects.filter( + event=OuterRef("pk"), + status__in=[AppealStatus.ACTIVE, AppealStatus.CLOSED], + ) + .annotate( + priority=Case( + When(status=AppealStatus.ACTIVE, then=Value(1)), + When(status=AppealStatus.CLOSED, then=Value(2)), + output_field=IntegerField(), + ) + ) + .order_by("priority", "-start_date") + ) + + active_dref_appeal_qs = appeal_priority_qs.filter( + atype=AppealType.DREF, + ) + + active_emergency_appeal_qs = appeal_priority_qs.filter( + atype=AppealType.APPEAL, + ) + + field_report_qs = FieldReport.objects.filter(event=OuterRef("pk")) + + approved_dref_qs = Dref.objects.filter( + event=OuterRef("pk"), + status=Dref.Status.APPROVED, + ) + + approved_ops_update_qs = DrefOperationalUpdate.objects.filter( + dref__event=OuterRef("pk"), + status=Dref.Status.APPROVED, + ).order_by("-created_at") + + approved_final_report_qs = DrefFinalReport.objects.filter( + dref__event=OuterRef("pk"), + status=Dref.Status.APPROVED, + ).order_by("-created_at") + return ( super() .get_queryset() - .select_related( - "dtype", - "parent_event", - ) - .prefetch_related( - "regions", - "countries", - "countries_for_preview", - Prefetch("key_figures", queryset=KeyFigure.objects.all()), - Prefetch("contacts", queryset=EventContact.objects.all()), + .annotate( + # Aggregated Values + response_activity_count=Count( + "emergency_projects", + distinct=True, + ), + active_deployments_count=Count( + "personneldeployment__personnel", + filter=Q( + personneldeployment__personnel__type=Personnel.TypeChoices.RR, + personneldeployment__personnel__start_date__date__lte=today, + personneldeployment__personnel__end_date__date__gte=today, + personneldeployment__personnel__is_active=True, + ), + distinct=True, + ), + surge_alerts_count=Count( + "surgealert", + distinct=True, + ), + # Stage + stage=Case( + When( + Exists(active_emergency_appeal_qs), + then=Value(EventStage.EMERGENCY_APPEAL), + ), + When( + Exists(approved_final_report_qs), + then=Value(EventStage.DREF_FINAL_REPORT), + ), + When( + Exists(approved_ops_update_qs), + then=Value(EventStage.DREF_OPERATIONAL_UPDATE), + ), + When( + Exists(approved_dref_qs), + then=Value(EventStage.DREF_APPLICATION), + ), + # If there is an active appeal of DREF type, but no approved DREF yet, + # we consider the emergency to be in the Dref Appeal only stage. + # Reaches here only if no approved DREF/ops-update/final-report exists + # So an active appeal type DREF appeal with no approved DREF = DREF_APPEAL_ONLY stage. + When( + Exists(active_dref_appeal_qs), + then=Value(EventStage.DREF_APPEAL_ONLY), + ), + When( + Exists(FieldReport.objects.filter(event=OuterRef("pk"))), + then=Value(EventStage.FIELD_REPORT), + ), + default=Value(None), + output_field=IntegerField(null=True), + ), ) .annotate( - first_field_report_id=Subquery( - FieldReport.objects.filter(event=OuterRef("pk")) - .order_by( - "fr_num", - "updated_at", - ) - .values("id")[:1] + # Passing values for the current stage's instance, + # to avoid extra queries in serializer. + stage_appeal_id=Case( + When( + stage=EventStage.EMERGENCY_APPEAL, + then=Subquery(active_emergency_appeal_qs.values("id")[:1]), + ), + When( + stage=EventStage.DREF_APPEAL_ONLY, + then=Subquery(active_dref_appeal_qs.values("id")[:1]), + ), + default=Value(None), + output_field=IntegerField(null=True), ), - latest_field_report_id=Subquery( - FieldReport.objects.filter(event=OuterRef("pk")) - .order_by( - "-fr_num", - "-updated_at", - ) - .values("id")[:1] + stage_dref_id=Case( + When( + stage__in=[ + EventStage.DREF_APPLICATION, + EventStage.DREF_OPERATIONAL_UPDATE, + EventStage.DREF_FINAL_REPORT, + ], + then=Subquery(approved_dref_qs.values("id")[:1]), + ), + default=Value(None), + output_field=IntegerField(null=True), + ), + stage_final_report_id=Case( + When( + stage=EventStage.DREF_FINAL_REPORT, + then=Subquery( + DrefFinalReport.objects.filter( + dref__id=OuterRef("stage_dref_id"), status=Dref.Status.APPROVED + ).values("id")[:1] + ), + ), + default=Value(None), + output_field=IntegerField(null=True), + ), + stage_ops_update_id=Case( + When( + stage__in=[ + EventStage.DREF_OPERATIONAL_UPDATE, + EventStage.DREF_FINAL_REPORT, + ], + then=Subquery( + DrefOperationalUpdate.objects.filter( + dref__id=OuterRef("stage_dref_id"), status=Dref.Status.APPROVED + ).values("id")[:1] + ), + ), + default=Value(None), + output_field=IntegerField(null=True), + ), + stage_field_report_id=Case( + When( + stage=EventStage.FIELD_REPORT, + then=Subquery(field_report_qs.order_by("-updated_at", "-fr_num").values("id")[:1]), + ), + default=Value(None), + output_field=IntegerField(null=True), + ), + first_field_report_created_at=Subquery(field_report_qs.order_by("created_at", "fr_num").values("created_at")[:1]), + latest_field_report_created_at=Subquery( + field_report_qs.order_by("-created_at", "-fr_num").values("created_at")[:1] + ), + ) + .select_related("dtype") + .prefetch_related( + Prefetch( + "countries", + queryset=Country.objects.select_related("region"), + ), + Prefetch( + "districts", + queryset=District.objects.select_related("country"), + ), + Prefetch( + "key_figures", + queryset=KeyFigure.objects.all(), + ), + Prefetch( + "contacts", + queryset=EventContact.objects.all(), + ), + Prefetch( + "links", + queryset=EventLink.objects.all(), + ), + Prefetch( + "featured_documents", + queryset=EventFeaturedDocument.objects.order_by("-id"), ), - appeal_id=Subquery(Appeal.objects.filter(event=OuterRef("pk")).order_by("-created_at").values("id")[:1]), ) ) diff --git a/api/enums.py b/api/enums.py index 115855b4d..6d9b843c2 100644 --- a/api/enums.py +++ b/api/enums.py @@ -21,4 +21,5 @@ "profile_org_types": models.Profile.OrgTypes, "supporting_type": models.CountrySupportingPartner.SupportingPartnerType, "event_source": models.Event.EventSource, + "emergency_stage": models.EventStage, } diff --git a/api/factories/event.py b/api/factories/event.py index 78588a825..c868ff5de 100644 --- a/api/factories/event.py +++ b/api/factories/event.py @@ -26,6 +26,7 @@ class Meta: name = fuzzy.FuzzyText(length=50) slug = fuzzy.FuzzyText(length=50) dtype = factory.SubFactory(DisasterTypeFactory) + source = fuzzy.FuzzyChoice(Event.EventSource) @factory.post_generation def districts(self, create, extracted, **kwargs): diff --git a/api/management/commands/index_and_notify.py b/api/management/commands/index_and_notify.py index 434964774..006805f55 100644 --- a/api/management/commands/index_and_notify.py +++ b/api/management/commands/index_and_notify.py @@ -1035,7 +1035,7 @@ def handle(self, *args, **options): condR = Q(real_data_update__gte=time_diff) # instead of modified at cond2 = ~Q(previous_update__gte=time_diff_1_day) # negate (~) no previous_update in the last day, so send once a day condF = Q( - source=Event.EventSource.NEW_REPORT + source=Event.EventSource.NEW_FIELD_REPORT ) # exclude those events that were generated from field reports, to avoid 2x notif. condE = Q(status=CronJobStatus.ERRONEOUS) diff --git a/api/management/commands/ingest_mdb.py b/api/management/commands/ingest_mdb.py index 6cfa7c57b..5fc7a77f1 100644 --- a/api/management/commands/ingest_mdb.py +++ b/api/management/commands/ingest_mdb.py @@ -248,7 +248,7 @@ def handle(self, *args, **options): "dtype": report_dtype, "disaster_start_date": datetime.utcnow().replace(tzinfo=timezone.utc), "auto_generated": True, - "source": Event.EventSource.REPORT_INGEST, + "source": Event.EventSource.FIELD_REPORT_DMIS_INGEST, } event = Event(**event_record) event.save() diff --git a/api/management/commands/migrate_event_source.py b/api/management/commands/migrate_event_source.py deleted file mode 100644 index 62cc0989f..000000000 --- a/api/management/commands/migrate_event_source.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.core.management.base import BaseCommand - -from api.models import Event - - -class Command(BaseCommand): - help = "Update event sources based on name prefix and auto-generated status" - - def handle(self, *args, **options): - queryset = Event.objects.filter(auto_generated=True, source=0) - self.stdout.write( - self.style.NOTICE( - f"{queryset.count()} events will be assigned a source (GDACS or Manual Input) based on name prefix." - ) - ) - to_update = [] - for event in queryset.iterator(): - if event.name.startswith("GDACS"): - event.source = Event.EventSource.GDACS - self.stdout.write(self.style.NOTICE(f"Updating {event.name} source to {Event.EventSource.GDACS.label}")) - else: - event.source = Event.EventSource.Manual_Input - self.stdout.write(self.style.NOTICE(f"Updating {event.name} source to {Event.EventSource.Manual_Input.label}")) - event.auto_generated = False - to_update.append(event) - - updated_event = Event.objects.bulk_update(to_update, ["source", "auto_generated"]) - self.stdout.write(self.style.SUCCESS(f"Total {updated_event} Events source have been updated successfully.")) diff --git a/api/migrations/0232_remove_event_auto_generated_source_event_source_and_more.py b/api/migrations/0232_remove_event_auto_generated_source_event_source_and_more.py index 157b8dce9..c81006a44 100644 --- a/api/migrations/0232_remove_event_auto_generated_source_event_source_and_more.py +++ b/api/migrations/0232_remove_event_auto_generated_source_event_source_and_more.py @@ -1,27 +1,93 @@ -# Generated by Django 4.2.29 on 2026-05-12 08:28 +# Generated by Django 4.2.30 on 2026-05-21 04:49 from django.db import migrations, models -class Migration(migrations.Migration): +def migrate_sources(apps, _): + """ + Populate the source field for Event records: + - Non auto generated events are mapped to MANUAL_INPUT. + - Events that are auto generated and have auto_generated_source are mapped to the corresponding enum value. + - Events that are auto generated but do not have auto_generated_source are mapped to MANUAL_INPUT. + - For WHO, the who_guid field is populated with the id extracted from the auto_generated_source field. + """ + + Event = apps.get_model("api", "Event") + + for obj in Event.objects.iterator(): + update_fields = ["source"] + + if not obj.auto_generated: + source = 100 # MANUAL_INPUT + + else: + raw_source = (obj.auto_generated_source or "").lower() + if "gdacs" in raw_source: + source = 110 # GDACS + + elif "who.int" in raw_source: + source = 120 + + parts = raw_source.strip().split(".") + who_id = parts[-1].strip() + try: + obj.who_guid = int(who_id) + update_fields.append("who_guid") + except (ValueError, TypeError): + pass + elif "field report dmis ingest" in raw_source: + source = 130 # FIELD_REPORT_DMIS_INGEST + elif "field report admin" in raw_source: + source = 140 # FIELD_REPORT_ADMIN + elif "appeal" in raw_source: + source = 150 # APPEAL_ADMIN + elif "new field report" in raw_source: + source = 160 # NEW_FIELD_REPORT + else: + # Using title for backward compatibility, as some events have auto_generated_source empty but title containing "GDACS" + if obj.name and obj.name.lower().startswith("gdacs"): + source = 110 # GDACS + else: + source = 100 # MANUAL_INPUT + obj.auto_generated = False + update_fields.append("auto_generated") + obj.source = source + obj.save(update_fields=update_fields) + + +class Migration(migrations.Migration): dependencies = [ - ('api', '0231_alter_export_export_type'), + ("api", "0231_alter_export_export_type"), ] operations = [ - migrations.RemoveField( - model_name='event', - name='auto_generated_source', - ), migrations.AddField( - model_name='event', - name='source', - field=models.IntegerField(choices=[(100, 'Manual input'), (110, 'GDACs scraper'), (120, 'WHO scraper'), (130, 'Field report DMIS ingest'), (140, 'Field report admin'), (150, 'Appeal admin'), (160, 'New field report'), (170, 'DREF')], default=100, verbose_name='Event source'), + model_name="event", + name="source", + field=models.IntegerField( + choices=[ + (100, "Manual input"), + (110, "GDACs scraper"), + (120, "WHO scraper"), + (130, "Field report DMIS ingest"), + (140, "Field report admin"), + (150, "Appeal admin"), + (160, "New field report"), + (170, "DREF"), + ], + default=100, + verbose_name="Event source", + ), ), migrations.AddField( - model_name='event', - name='who_guid', - field=models.IntegerField(blank=True, null=True, verbose_name='Who guid'), + model_name="event", + name="who_guid", + field=models.IntegerField(blank=True, null=True, verbose_name="Who guid"), + ), + migrations.RunPython(migrate_sources, reverse_code=migrations.RunPython.noop), + migrations.RemoveField( + model_name="event", + name="auto_generated_source", ), ] diff --git a/api/models.py b/api/models.py index 804dfb014..d8facc360 100644 --- a/api/models.py +++ b/api/models.py @@ -761,6 +761,16 @@ def snippet_image_path(instance, filename): return "emergencies/%s/%s" % (instance.id, filename) +# NOTE: Stage for the emergency timeline +class EventStage(models.IntegerChoices): + EMERGENCY_APPEAL = 1, _("Emergency Appeal") + DREF_APPLICATION = 2, _("DREF Application") + DREF_OPERATIONAL_UPDATE = 3, _("DREF Operational Update") + DREF_FINAL_REPORT = 4, _("DREF Final Report") + FIELD_REPORT = 5, _("Field Report") + DREF_APPEAL_ONLY = 6, _("DREF") + + # NOTE: If ever in future we need to create an api to update the event table # we also need to make sure to add appropriate signal to create ifrc severity level event history @reversion.register() @@ -769,7 +779,7 @@ class Event(models.Model): class EventSource(models.IntegerChoices): - Manual_Input = 100, _("Manual input") + MANUAL_INPUT = 100, _("Manual input") """MANUAL_INPUT: Event data manually entered by a user through the event administration interface.""" GDACS = 110, _("GDACs scraper") @@ -778,17 +788,17 @@ class EventSource(models.IntegerChoices): WHO = 120, _("WHO scraper") """WHO: Event data automatically ingested from the (WHO) scraper.""" - REPORT_INGEST = 130, _("Field report DMIS ingest") - """REPORT_INGEST: Event data imported through the DMIS field report.""" + FIELD_REPORT_DMIS_INGEST = 130, _("Field report DMIS ingest") + """FIELD_REPORT_DMIS_INGEST: Event data imported through the DMIS field report.""" - REPORT_ADMIN = 140, _("Field report admin") - """REPORT_ADMIN: Event data created or modified via the field report administration interface.""" + FIELD_REPORT_ADMIN = 140, _("Field report admin") + """FIELD_REPORT_ADMIN: Event data created or modified via the field report administration interface.""" APPEAL_ADMIN = 150, _("Appeal admin") """APPEAL_ADMIN: Event data created or managed through the appeal administration interface.""" - NEW_REPORT = 160, _("New field report") - """NEW_REPORT: Event data originating from newly created field reports.""" + NEW_FIELD_REPORT = 160, _("New field report") + """NEW_FIELD_REPORT: Event data originating from newly created field reports.""" DREF = 170, _("DREF") """DREF: Event originating records.""" @@ -853,7 +863,7 @@ class EventSource(models.IntegerChoices): previous_update = models.DateTimeField(verbose_name=_("previous update"), null=True, blank=True) auto_generated = models.BooleanField(verbose_name=_("auto generated"), default=False, editable=False) - source = models.IntegerField(choices=EventSource.choices, default=EventSource.Manual_Input, verbose_name=_("Event source")) + source = models.IntegerField(choices=EventSource.choices, default=EventSource.MANUAL_INPUT, verbose_name=_("Event source")) # Meant to give the organization a way of highlighting certain, important events. is_featured = models.BooleanField(default=False, verbose_name=_("is featured on home page")) diff --git a/api/serializers.py b/api/serializers.py index 2546495ce..980ca40a1 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -4,7 +4,10 @@ from django.conf import settings from django.contrib.auth.models import Permission, User +from django.contrib.gis.db.models import OuterRef, Subquery, Value from django.db import models, transaction +from django.db.models.fields import IntegerField +from django.db.models.query import Prefetch from django.utils import timezone from django.utils.translation import get_language as django_get_language from drf_spectacular.utils import extend_schema_field @@ -14,7 +17,13 @@ from api.tasks import generate_export_pdf from api.utils import CountryValidator, RegionValidator, generate_eap_export_url from deployments.models import EmergencyProject, Personnel, PersonnelDeployment -from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate +from dref.models import ( + Dref, + DrefFinalReport, + DrefOperationalUpdate, + PlannedIntervention, + ProposedAction, +) from eap.models import EAPRegistration, FullEAP, SimplifiedEAP from lang.models import String from lang.serializers import ModelSerializer @@ -52,6 +61,7 @@ EventFeaturedDocument, EventLink, EventSeverityLevelHistory, + EventStage, Export, ExternalPartner, FieldReport, @@ -1060,6 +1070,8 @@ class Meta: class ListMiniEventSerializer(ModelSerializer): dtype = DisasterTypeSerializer(required=False) countries_for_preview = MiniCountrySerializer(many=True, read_only=True) + latest_field_report_id = serializers.IntegerField(read_only=True) + source_display = serializers.CharField(source="get_source_display", read_only=True) class Meta: model = Event @@ -1069,8 +1081,10 @@ class Meta: "slug", "dtype", "source", + "source_display", "emergency_response_contact_email", "countries_for_preview", + "latest_field_report_id", ) @@ -2126,6 +2140,107 @@ class Meta: fields = "__all__" +# NOTE: Using this specific serializer for emergency page schema +# Contains information from latest field report mostly and some fields from first field report +class EmergencyPreviousFieldReportSerializer( + serializers.ModelSerializer, +): + class Meta: + model = FieldReport + fields = ( + "id", + "event_id", + "fr_num", + "start_date", + "report_date", + "created_at", + "updated_at", + ) + + +class EmergencyFieldReportSerializer( + serializers.ModelSerializer, +): + actions_taken = ActionsTakenSerializer(many=True) + status_display = serializers.CharField(source="get_status_display", read_only=True) + appeal_display = serializers.CharField(source="get_appeal_display", read_only=True) + bulletin_display = serializers.CharField(source="get_bulletin_display", read_only=True) + dtype = DisasterTypeSerializer(read_only=True) + dref_display = serializers.CharField(source="get_dref_display", read_only=True) + visibility_display = serializers.CharField(source="get_visibility_display", read_only=True) + imminent_dref_display = serializers.CharField(source="get_imminent_dref_display", read_only=True) + contacts = FieldReportContactSerializer(many=True) + countries = MiniCountrySerializer(many=True) + districts = MiniDistrictSerializer(many=True) + + first_fr_ns_request_assistance = serializers.BooleanField(read_only=True) + first_fr_request_assistance = serializers.BooleanField(read_only=True) + + class Meta: + model = FieldReport + fields = ( + "id", + "summary", + "countries", + "districts", + "status", + "status_display", + "appeal", + "appeal_display", + "bulletin", + "bulletin_display", + "imminent_dref", + "imminent_dref_display", + "visibility", + "visibility_display", + "dref", + "dref_display", + "description", + "dtype", + "contacts", + # Key Figures + # IFRC figures + "num_injured", + "num_dead", + "num_missing", + "num_affected", + "num_displaced", + "epi_num_dead", + "num_assisted", + "num_localstaff", + "num_volunteers", + "num_expats_delegates", + "num_highest_risk", + "num_potentially_affected", + # Government figures + "gov_num_injured", + "gov_num_dead", + "gov_num_missing", + "gov_num_affected", + "gov_num_displaced", + "gov_num_assisted", + "gov_num_highest_risk", + "gov_num_potentially_affected", + # Other figures + "other_num_injured", + "other_num_dead", + "other_num_missing", + "other_num_affected", + "other_num_displaced", + "other_num_assisted", + "other_num_highest_risk", + "other_num_potentially_affected", + "start_date", + "report_date", + "created_at", + "updated_at", + "actions_taken", + # First field report related fields, annotate + "first_fr_ns_request_assistance", + "first_fr_request_assistance", + ) + + class DetailFieldReportSerializer(FieldReportEnumDisplayMixin, ModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2217,7 +2332,7 @@ def create_event(self, report): summary=report.description or "", disaster_start_date=report.start_date, auto_generated=True, - source=Event.EventSource.NEW_REPORT, + source=Event.EventSource.NEW_FIELD_REPORT, visibility=report.visibility, **{TRANSLATOR_ORIGINAL_LANGUAGE_FIELD_NAME: django_get_language()}, ) @@ -2684,23 +2799,59 @@ class Meta: fields = "__all__" -class DetailEmergencySerializer(serializers.ModelSerializer): +class TimelineEmergencyFieldReportSerializer(serializers.ModelSerializer): + class Meta: + model = FieldReport + fields = ( + "id", + "report_date", + "fr_num", + "start_date", + ) + + +class DetailEmergencySerializer(ModelSerializer): + from dref.serializers import EmergencyDrefSerializer + contacts = EventContactSerializer(many=True, read_only=True) key_figures = KeyFigureSerializer(many=True, read_only=True) countries = MiniCountrySerializer(many=True, read_only=True) ifrc_severity_level_display = serializers.CharField(source="get_ifrc_severity_level_display", read_only=True) visibility_display = serializers.CharField(source="get_visibility_display", read_only=True) source_display = serializers.CharField(source="get_source_display", read_only=True) + links = EventLinkSerializer(many=True, read_only=True) + districts = MiniDistrictSerializer(many=True) + featured_documents = EventFeaturedDocumentSerializer(many=True, read_only=True) + # NOTE: Populated from Queryset using Annotate - first_field_report_id = serializers.IntegerField(read_only=True) - latest_field_report_id = serializers.IntegerField(read_only=True) - appeal_id = serializers.IntegerField(read_only=True) + + # Aggregated values + response_activity_count = serializers.IntegerField(read_only=True) + active_deployments_count = serializers.IntegerField(read_only=True) + surge_alerts_count = serializers.IntegerField(read_only=True) + + # Stages + stage = serializers.IntegerField(read_only=True) + stage_display = serializers.SerializerMethodField() + + field_report = serializers.SerializerMethodField() + appeal = serializers.SerializerMethodField() + dref = serializers.SerializerMethodField() + + # Operational timeframe + first_field_report_created_at = serializers.DateTimeField(read_only=True) + latest_field_report_created_at = serializers.DateTimeField(read_only=True) + + timeline_field_reports = serializers.SerializerMethodField() class Meta: model = Event fields = ( + "id", + "slug", "name", "dtype", + "glide", "countries", "summary", "disaster_start_date", @@ -2710,10 +2861,11 @@ class Meta: "key_figures", "is_featured", "is_featured_region", + "tab_one_title", + "tab_two_title", + "tab_three_title", "hide_attached_field_reports", "hide_field_report_map", - "id", - "slug", "ifrc_severity_level", "ifrc_severity_level_display", "ifrc_severity_level_update_date", @@ -2722,6 +2874,9 @@ class Meta: "visibility", "visibility_display", "contacts", + "links", + "districts", + "featured_documents", "num_injured", "num_dead", "num_missing", @@ -2730,7 +2885,151 @@ class Meta: "created_at", "updated_at", "previous_update", - "first_field_report_id", - "latest_field_report_id", - "appeal_id", + # Aggregated values + "response_activity_count", + "active_deployments_count", + "surge_alerts_count", + # Stages + "stage", + "stage_display", + "field_report", + "appeal", + "dref", + "first_field_report_created_at", + "latest_field_report_created_at", + "timeline_field_reports", + ) + + @extend_schema_field(TimelineEmergencyFieldReportSerializer(many=True)) + def get_timeline_field_reports(self, event): + field_reports = ( + FieldReport.objects.filter(event=event) + .order_by( + "-updated_at", + "-fr_num", + ) + .values( + "id", + "report_date", + "fr_num", + "start_date", + ) ) + serializer = TimelineEmergencyFieldReportSerializer(field_reports, many=True) + return serializer.data + + def _get_stage_instance(self, event): + stage = getattr(event, "stage") + if stage == EventStage.FIELD_REPORT and event.stage_field_report_id: + _first_field_report_queryset = FieldReport.objects.filter(event_id=OuterRef("event_id")).order_by( + "created_at", "fr_num" + ) + instance = ( + FieldReport.objects.select_related( + "dtype", + "event", + ) + .annotate( + first_fr_ns_request_assistance=Subquery(_first_field_report_queryset.values("ns_request_assistance")[:1]), + first_fr_request_assistance=Subquery(_first_field_report_queryset.values("request_assistance")[:1]), + ) + .prefetch_related( + "contacts", + "countries", + "districts", + Prefetch( + "actions_taken", + queryset=ActionsTaken.objects.prefetch_related("actions"), + ), + ) + .get(pk=event.stage_field_report_id) + ) + return instance + + elif ( + stage + in [ + EventStage.EMERGENCY_APPEAL, + EventStage.DREF_APPEAL_ONLY, + ] + and event.stage_appeal_id + ): + instance = Appeal.objects.get(pk=event.stage_appeal_id) + return instance + elif ( + stage + in [ + EventStage.DREF_APPLICATION, + EventStage.DREF_OPERATIONAL_UPDATE, + EventStage.DREF_FINAL_REPORT, + ] + and event.stage_dref_id + ): + instance = ( + Dref.objects.select_related( + "country", + "disaster_type", + "cover_image", + "cover_image__created_by", + ) + .annotate( + operational_update_id=Value( + event.stage_ops_update_id, + output_field=IntegerField(null=True), + ), + final_report_id=Value( + event.stage_final_report_id, + output_field=IntegerField(null=True), + ), + ) + .prefetch_related( + "district", + Prefetch( + "planned_interventions", + queryset=PlannedIntervention.objects.prefetch_related("indicators"), + ), + Prefetch( + "proposed_action", + queryset=ProposedAction.objects.prefetch_related("activities"), + ), + ) + .get(pk=event.stage_dref_id) + ) + return instance + + 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()) + 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()) + def get_appeal(self, event): + if getattr(event, "stage", None) not in ( + EventStage.EMERGENCY_APPEAL, + EventStage.DREF_APPEAL_ONLY, + ): + return None + instance = self._get_stage_instance(event) + return RelatedAppealSerializer(instance, context=self.context).data if instance else None + + @extend_schema_field(EmergencyDrefSerializer()) + def get_dref(self, event): + if getattr(event, "stage", None) not in ( + EventStage.DREF_APPLICATION, + EventStage.DREF_OPERATIONAL_UPDATE, + EventStage.DREF_FINAL_REPORT, + ): + return None + instance = self._get_stage_instance(event) + if not instance: + return None + + from dref.serializers import EmergencyDrefSerializer + + return EmergencyDrefSerializer(instance, context=self.context).data diff --git a/api/test_views.py b/api/test_views.py index 87e24c6d9..f302d56a8 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -7,8 +7,10 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings from django.urls import reverse +from django.utils.dateparse import parse_datetime import api.models as models +from api.factories.country import CountryFactory from api.factories.disaster_type import DisasterTypeFactory from api.factories.event import ( AppealFactory, @@ -19,9 +21,14 @@ EventLinkFactory, ) from api.factories.field_report import FieldReportFactory -from api.models import Profile, VisibilityChoices +from api.models import AppealStatus, EventStage, Profile, VisibilityChoices from deployments.factories.user import UserFactory -from dref.models import DrefFile +from dref.factories.dref import ( + DrefFactory, + DrefFinalReportFactory, + DrefOperationalUpdateFactory, +) +from dref.models import Dref, DrefFile from main.test_case import APITestCase from per.factories import OpsLearningFactory @@ -1063,20 +1070,12 @@ def setUp(self): event=self.event1, ) - self.field_report1 = FieldReportFactory.create( + self.field_report = FieldReportFactory.create( event=self.event1, created_at=datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), updated_at=datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), fr_num=50, ) - - self.field_report2 = FieldReportFactory.create( - event=self.event1, - created_at=datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), - updated_at=datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc), - fr_num=20, - ) - self.event2 = EventFactory.create( dtype=self.disaster_type, source=models.Event.EventSource.WHO, @@ -1098,13 +1097,6 @@ def setUp(self): amount_funded=1899999, ) - self.url = "/api/v2/emergency/" - - def test_get_emergency_list(self): - response = self.client.get(self.url) - self.assert_200(response) - self.assertEqual(response.data["count"], 3) - def test_retrive_emergency_detail(self): url = f"/api/v2/emergency/{self.event1.id}/" response = self.client.get(url) @@ -1114,27 +1106,425 @@ def test_retrive_emergency_detail(self): self.assertEqual(response.data["name"], self.event1.name) self.assertEqual(response.data["source"], models.Event.EventSource.GDACS) - # first field report id check - self.assertEqual(response.data["first_field_report_id"], self.field_report2.id) - # latest check field report - self.assertEqual(response.data["latest_field_report_id"], self.field_report1.id) + # Stage check + self.assertEqual(response.data["stage"], EventStage.FIELD_REPORT, response.data) + self.assertEqual(response.data["field_report"]["id"], self.field_report.id, response.data) - # Filter Tests - def test_filter_by_who_source(self): - url = f"{self.url}?source=120" - response = self.client.get(url) - self.assert_200(response) - self.assertEqual(response.data["count"], 1) - self.assertEqual(response.data["results"][0]["source"], models.Event.EventSource.WHO) - def test_filter_by_appeal_source(self): - url = f"{self.url}?source=150" - response = self.client.get(url) - self.assert_200(response) - self.assertEqual(response.data["count"], 1) - self.assertEqual(response.data["results"][0]["source"], models.Event.EventSource.APPEAL_ADMIN) +class EmergencyStageTestCase(APITestCase): + """ + Tests for the stage annotation and stage-specific nested serializer + fields (field_report / appeal / dref) on GET /api/v2/emergency// + """ - def test_filter_by_source_no_match(self): - url = f"{self.url}?source=500" - response = self.client.get(url) - self.assert_400(response) + def setUp(self): + super().setUp() + self.disaster_type = DisasterTypeFactory.create(name="Flood") + self.country = CountryFactory.create(name="country1", iso3="DEP", iso="DE") + + def _url(self, event): + return f"/api/v2/emergency/{event.id}/" + + def _get(self, event): + return self.client.get(self._url(event)) + + def _approved_dref(self, event): + return DrefFactory.create( + event=event, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + ) + + def _approved_ops_update(self, dref): + return DrefOperationalUpdateFactory.create( + dref=dref, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + ) + + def _approved_final_report(self, dref): + return DrefFinalReportFactory.create( + dref=dref, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + ) + + def _active_emergency_appeal(self, event): + return AppealFactory.create( + event=event, + dtype=self.disaster_type, + status=AppealStatus.ACTIVE, + atype=AppealType.APPEAL, + ) + + def test_stage_is_none(self): + event = EventFactory.create( + dtype=self.disaster_type, + source=models.Event.EventSource.MANUAL_INPUT, + ) + + data = self._get(event).data + + self.assertIsNone(data["stage"]) + self.assertIsNone(data["stage_display"]) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["appeal"]) + self.assertIsNone(data["dref"]) + + def test_stage_field_report(self): + event = EventFactory.create(dtype=self.disaster_type, source=models.Event.EventSource.MANUAL_INPUT) + field_report = FieldReportFactory.create(event=event) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.FIELD_REPORT) + self.assertEqual(data["stage_display"], EventStage(EventStage.FIELD_REPORT).label) + self.assertEqual(data["field_report"]["id"], field_report.id) + self.assertIsNone(data["appeal"]) + self.assertIsNone(data["dref"]) + + def test_stage_field_report_returns_latest_by_updated_at_and_fr_num(self): + """ + When multiple field reports exist, the one ordered by + (-updated_at, -fr_num) should be returned. + """ + event = EventFactory.create(dtype=self.disaster_type) + first_fr = FieldReportFactory.create( + event=event, + dtype=self.disaster_type, + created_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + updated_at=datetime.datetime(2024, 6, 1, tzinfo=datetime.UTC), + fr_num=1, + ) + + latest_fr = FieldReportFactory.create( + event=event, + dtype=self.disaster_type, + created_at=datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC), + updated_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + fr_num=2, + ) + + data = self._get(event).data + + self.assertIsNotNone(data["field_report"], data) + self.assertEqual(data["field_report"]["id"], latest_fr.id) + self.assertEqual(data["stage"], EventStage.FIELD_REPORT) + self.assertEqual(parse_datetime(data["first_field_report_created_at"]), first_fr.created_at) + self.assertEqual(parse_datetime(data["latest_field_report_created_at"]), latest_fr.created_at) + + def test_stage_dref_application(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + DrefOperationalUpdateFactory.create( + dref=dref, + status=Dref.Status.DRAFT, + disaster_type=self.disaster_type, + country=self.country, + ) + + data = self._get(event).data + + self.assertEqual( + data["stage"], + EventStage.DREF_APPLICATION.value, + ) + + self.assertEqual( + data["stage_display"], + EventStage(EventStage.DREF_APPLICATION).label, + ) + + self.assertEqual(data["dref"]["id"], dref.id) + self.assertIsNone(data["dref"]["operational_update_details"]) + self.assertIsNone(data["dref"]["final_report_details"]) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["appeal"]) + + # After Final report create + dref_final_report = DrefFinalReportFactory.create( + dref=dref, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + ) + data = self._get(event).data + + self.assertEqual( + data["stage"], + EventStage.DREF_FINAL_REPORT.value, + ) + self.assertEqual( + data["stage_display"], + EventStage(EventStage.DREF_FINAL_REPORT).label, + ) + self.assertEqual(data["dref"]["id"], dref.id) + self.assertIsNone(data["dref"]["operational_update_details"]) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["appeal"]) + self.assertIsNotNone(data["dref"]["final_report_details"]) + self.assertEqual(data["dref"]["final_report_details"]["id"], dref_final_report.id) + + def test_stage_dref_operational_update(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + ops_update = self._approved_ops_update(dref) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.DREF_OPERATIONAL_UPDATE) + self.assertEqual(data["dref"]["id"], dref.id) + self.assertEqual(data["dref"]["operational_update_details"]["id"], ops_update.id) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["appeal"]) + + def test_stage_dref_final_report(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + final_report = self._approved_final_report(dref) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.DREF_FINAL_REPORT) + self.assertEqual(data["stage_display"], EventStage(EventStage.DREF_FINAL_REPORT).label) + self.assertEqual(data["dref"]["id"], dref.id) + self.assertEqual(data["dref"]["final_report_details"]["id"], final_report.id) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["appeal"]) + + def test_stage_emergency_appeal(self): + event = EventFactory.create( + dtype=self.disaster_type, + source=models.Event.EventSource.MANUAL_INPUT, + ) + appeal = self._active_emergency_appeal(event) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + self.assertEqual(data["stage_display"], EventStage(EventStage.EMERGENCY_APPEAL).label) + self.assertEqual(data["appeal"]["id"], appeal.id) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["dref"]) + + # If Field report is created + first_fr = FieldReportFactory.create( + event=event, + dtype=self.disaster_type, + created_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + updated_at=datetime.datetime(2024, 6, 1, tzinfo=datetime.UTC), + fr_num=1, + ) + latest_fr = FieldReportFactory.create( + event=event, + dtype=self.disaster_type, + created_at=datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC), + updated_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + fr_num=2, + ) + + data = self._get(event).data + # Emergency appeal stage should take priority over field report stage + # BUT Fields Report dates for latest and first should be present + self.assertEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + + self.assertEqual(parse_datetime(data["first_field_report_created_at"]), first_fr.created_at) + self.assertEqual(parse_datetime(data["latest_field_report_created_at"]), latest_fr.created_at) + + def test_inactive_appeal_does_not_trigger_emergency_appeal_stage(self): + """An appeal that is not ACTIVE should not resolve to EMERGENCY_APPEAL.""" + event = EventFactory.create(dtype=self.disaster_type) + AppealFactory.create( + event=event, + dtype=self.disaster_type, + status=AppealStatus.ARCHIVED, + atype=AppealType.APPEAL, + ) + + data = self._get(event).data + self.assertNotEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + + # CLOSED one also considered but ACTIVE takes over + AppealFactory.create( + event=event, + dtype=self.disaster_type, + status=AppealStatus.CLOSED, + atype=AppealType.APPEAL, + ) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + self.assertEqual(data["appeal"]["status"], AppealStatus.CLOSED) + + self._active_emergency_appeal(event) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + + # Active should take priority over closed and archived + self.assertEqual(data["appeal"]["status"], AppealStatus.ACTIVE) + + def test_emergency_appeal_takes_priority_over_entire_dref_chain(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + self._approved_ops_update(dref) + self._approved_final_report(dref) + self._active_emergency_appeal(event) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + + def test_dref_final_report_takes_priority_over_ops_update_and_application(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + self._approved_ops_update(dref) + self._approved_final_report(dref) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.DREF_FINAL_REPORT) + + def test_dref_ops_update_takes_priority_over_dref_application(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + self._approved_ops_update(dref) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.DREF_OPERATIONAL_UPDATE) + + # Create DRAFT Final report, stage should still be DREF_OPERATIONAL_UPDATE until Final report is APPROVED + DrefFinalReportFactory.create( + dref=dref, + status=Dref.Status.DRAFT, + disaster_type=self.disaster_type, + country=self.country, + ) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.DREF_OPERATIONAL_UPDATE) + self.assertIsNone(data["appeal"]) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["dref"]["final_report_details"]) + + def test_dref_application_takes_priority_over_field_report(self): + event = EventFactory.create(dtype=self.disaster_type) + FieldReportFactory.create(event=event) + dref = self._approved_dref(event) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.DREF_APPLICATION) + + # Create DRAFT ops update, stage should still be DREF Application until ops update is APPROVED + DrefOperationalUpdateFactory.create( + dref=dref, + status=Dref.Status.DRAFT, + disaster_type=self.disaster_type, + country=self.country, + ) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.DREF_APPLICATION) + self.assertIsNone(data["appeal"]) + self.assertIsNone(data["field_report"]) + + # Create DRAFT Final report, stage should still be DREF Aplication until Final report is APPROVED + DrefFinalReportFactory.create( + dref=dref, + status=Dref.Status.DRAFT, + disaster_type=self.disaster_type, + country=self.country, + ) + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.DREF_APPLICATION) + self.assertIsNone(data["appeal"]) + self.assertIsNone(data["field_report"]) + self.assertIsNone(data["dref"]["final_report_details"]) + + def fallback_to_dref_appeal_for_no_approved_dref(self): + """ + If no approved DREF application exists, but an ACTIVE appeal dref type exists, stage should resolve to DREF_APPEAL_ONLY + and not DREF. + """ + event = EventFactory.create(dtype=self.disaster_type) + AppealFactory.create( + event=event, + dtype=self.disaster_type, + status=AppealStatus.ACTIVE, + atype=AppealType.DREF, + ) + + data = self._get(event).data + + self.assertEqual(data["stage"], EventStage.EMERGENCY_APPEAL) + self.assertIsNone(data["dref"]) + self.assertIsNone(data["field_report"]) + self.assertIsNotNone(data["appeal"]) + + # If Field report is created + first_fr = FieldReportFactory.create( + event=event, + dtype=self.disaster_type, + created_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + updated_at=datetime.datetime(2024, 6, 1, tzinfo=datetime.UTC), + fr_num=1, + ) + latest_fr = FieldReportFactory.create( + event=event, + dtype=self.disaster_type, + created_at=datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC), + updated_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + fr_num=2, + ) + + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.DREF_APPEAL_ONLY) + + self.assertEqual(parse_datetime(data["first_field_report_created_at"]), first_fr.created_at) + self.assertEqual(parse_datetime(data["latest_field_report_created_at"]), latest_fr.created_at) + + def test_field_report_with_timeline_field_reports(self): + event = EventFactory.create(dtype=self.disaster_type) + FieldReportFactory.create_batch( + 3, + event=event, + dtype=self.disaster_type, + ) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.FIELD_REPORT, data) + self.assertIsNotNone(data["field_report"], data) + self.assertEqual(len(data["timeline_field_reports"]), 3) + + def test_dref_operational_update_with_timeline_ops_updates(self): + event = EventFactory.create(dtype=self.disaster_type) + dref = self._approved_dref(event) + DrefOperationalUpdateFactory.create_batch( + 3, + dref=dref, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + ) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.DREF_OPERATIONAL_UPDATE, data) + + dref_data = data.get("dref") + self.assertIsNotNone(dref_data, data) + + self.assertEqual(len(dref_data["timeline_operational_updates"]), 3) + + # NOTE: if Final report is created, stage should be DREF_FINAL_REPORT and previous operational updates should also show + DrefFinalReportFactory.create( + dref=dref, + status=Dref.Status.APPROVED, + disaster_type=self.disaster_type, + country=self.country, + ) + data = self._get(event).data + self.assertEqual(data["stage"], EventStage.DREF_FINAL_REPORT) + dref_data = data.get("dref") + self.assertIsNotNone(dref_data) + self.assertIsNotNone(dref_data["final_report_details"]) + self.assertEqual(len(dref_data["timeline_operational_updates"]), 3) diff --git a/assets b/assets index 6415a4b3a..2da588a3e 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 6415a4b3a8caded684bb00864c6efa102c2c63f5 +Subproject commit 2da588a3ead4878bf8fafd1d4b122e9c45b117d5 diff --git a/deployments/migrations/0095_alter_emergencyproject_event.py b/deployments/migrations/0095_alter_emergencyproject_event.py new file mode 100644 index 000000000..632195669 --- /dev/null +++ b/deployments/migrations/0095_alter_emergencyproject_event.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.30 on 2026-05-21 04:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0232_remove_event_auto_generated_source_event_source_and_more'), + ('deployments', '0094_erureadinesstype_ns_contribution'), + ] + + operations = [ + migrations.AlterField( + model_name='emergencyproject', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emergency_projects', to='api.event', verbose_name='Event'), + ), + ] diff --git a/deployments/models.py b/deployments/models.py index 1441be0f0..1f134dfd8 100644 --- a/deployments/models.py +++ b/deployments/models.py @@ -671,7 +671,10 @@ class ActivityStatus(models.TextChoices): related_name="+", ) event = models.ForeignKey( - Event, verbose_name=_("Event"), on_delete=models.CASCADE, related_name="+" + Event, + verbose_name=_("Event"), + on_delete=models.CASCADE, + related_name="emergency_projects", ) # this is the current operation districts = models.ManyToManyField( District, diff --git a/deployments/tests.py b/deployments/tests.py index 5f56899ef..af709512d 100644 --- a/deployments/tests.py +++ b/deployments/tests.py @@ -687,9 +687,24 @@ def setUp(self): disaster_start_date=datetime.datetime(2025, 3, 1), ) - self.country1 = country.CountryFactory(name="Test Country1") - self.country2 = country.CountryFactory(name="Test Country2") - self.country3 = country.CountryFactory(name="Test Country3") + self.country1 = country.CountryFactory.create( + name="Test Country1", + iso3="Ts1", + iso="T1", + society_name="Test Society", + ) + self.country2 = country.CountryFactory.create( + name="Test Country2", + iso3="TS2", + iso="T2", + society_name="Test Society2", + ) + self.country3 = country.CountryFactory.create( + name="Test Country3", + iso3="TS3", + iso="T3", + society_name="Test Society3", + ) self.eru_owner = ERUOwnerFactory( national_society_country=self.country1, diff --git a/dref/admin.py b/dref/admin.py index 6c34c1032..56f58a3d0 100644 --- a/dref/admin.py +++ b/dref/admin.py @@ -90,7 +90,7 @@ class DrefAdmin(CompareVersionAdmin, TranslationAdmin, admin.ModelAdmin): "images", "cover_image", "users", - "field_report", + "event", "supporting_document", "national_society_actions", "needs_identified", @@ -112,7 +112,7 @@ def get_queryset(self, request): "event_map", "cover_image", "country", - "field_report", + "event", "supporting_document", ) .prefetch_related("planned_interventions", "needs_identified", "national_society_actions", "users", "risk_security") diff --git a/dref/filter_set.py b/dref/filter_set.py index 2e27ebf18..cc41bad90 100644 --- a/dref/filter_set.py +++ b/dref/filter_set.py @@ -40,6 +40,7 @@ class BaseDrefFilterSet(filters.FilterSet): queryset=DisasterType.objects.all(), ) appeal_code = filters.CharFilter(field_name="appeal_code", lookup_expr="icontains") + event = filters.NumberFilter(field_name="event", lookup_expr="exact") class CompletedDrefOperationsFilterSet(BaseDrefFilterSet): diff --git a/dref/management/commands/migrate_dref_event.py b/dref/management/commands/migrate_dref_event.py new file mode 100644 index 000000000..74b98d748 --- /dev/null +++ b/dref/management/commands/migrate_dref_event.py @@ -0,0 +1,107 @@ +from django.core.management.base import BaseCommand +from django.db.models import Case, IntegerField, Value, When + +from api.models import Appeal, AppealType +from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate + + +class Command(BaseCommand): + help = "Migrate related Event from Appeal / Dref Final / Operational Update to Dref" + + def handle(self, *args, **options): + self.stdout.write(self.style.NOTICE("Starting migration of events to Dref...")) + + appeal_event_map = dict( + Appeal.objects.filter( + atype=AppealType.DREF, + event__isnull=False, + ) + .exclude(code__isnull=True) + .values_list("code", "event_id") + ) + + final_report_map = dict( + DrefFinalReport.objects.exclude(appeal_code__isnull=True) + .annotate( + approval_priority=Case( + When(status=Dref.Status.APPROVED, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("dref_id", "approval_priority") + .distinct("dref_id") + .values_list("dref_id", "appeal_code") + ) + + operational_update_map = dict( + DrefOperationalUpdate.objects.exclude(appeal_code__isnull=True) + .annotate( + approval_priority=Case( + When(status=Dref.Status.APPROVED, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("dref_id", "approval_priority", "-created_at") + .distinct("dref_id") + .values_list("dref_id", "appeal_code") + ) + + dref_queryset = Dref.objects.only("id", "title", "appeal_code", "event_id") + self.stdout.write(self.style.NOTICE("\nMigrating Dref Event Mapping")) + + drefs_to_update = [] + for dref in dref_queryset.iterator(): + self.stdout.write(self.style.NOTICE(f"\nDref: {dref.title} (id={dref.id})")) + + appeal_code = None + matched_with = None + + self.stdout.write(self.style.NOTICE("\tChecking Final Report")) + if final_report_map.get(dref.id): + appeal_code = final_report_map[dref.id] + matched_with = "DREF FINAL REPORT" + self.stdout.write(self.style.SUCCESS(f"\t\tFound appeal_code from Final Report ({appeal_code})")) + else: + self.stdout.write(self.style.WARNING("\t\tNo appeal_code in Final Report")) + + if not appeal_code: + self.stdout.write(self.style.NOTICE("\tChecking Operational Update")) + if operational_update_map.get(dref.id): + appeal_code = operational_update_map[dref.id] + matched_with = "DREF OPERATIONAL UPDATE" + self.stdout.write(self.style.SUCCESS(f"\t\tFound appeal_code from Operational Update ({appeal_code})")) + else: + self.stdout.write(self.style.WARNING("\t\tNo appeal_code in Operational Update")) + + if not appeal_code: + self.stdout.write(self.style.NOTICE("\tChecking Dref")) + if dref.appeal_code: + appeal_code = dref.appeal_code + matched_with = "DREF" + self.stdout.write(self.style.SUCCESS(f"\t\tFound appeal_code from Dref ({appeal_code})")) + else: + self.stdout.write(self.style.WARNING("\t\tNo appeal_code in Dref")) + + if not appeal_code: + self.stdout.write(self.style.WARNING("\tNo appeal_code found, skipping...")) + continue + + new_event_id = appeal_event_map.get(appeal_code) + if not new_event_id: + self.stdout.write(self.style.WARNING(f"\tNo matching Appeal found for appeal_code={appeal_code}, skipping...")) + continue + + if dref.event_id and dref.event_id != new_event_id: + self.stdout.write( + self.style.WARNING(f"\tConflict: existing_event_id={dref.event_id}, new_event_id={new_event_id} (skipped)") + ) + continue + + dref.event_id = new_event_id + drefs_to_update.append(dref) + self.stdout.write(self.style.SUCCESS(f"\tUpdating event_id={new_event_id} using {matched_with}")) + + Dref.objects.bulk_update(drefs_to_update, ["event_id"]) + self.stdout.write(self.style.SUCCESS(f"\nSuccessfully updated {len(drefs_to_update)} Dref records\n")) diff --git a/dref/migrations/0089_remove_dref_field_report_dref_event.py b/dref/migrations/0089_remove_dref_field_report_dref_event.py new file mode 100644 index 000000000..ad25ecffb --- /dev/null +++ b/dref/migrations/0089_remove_dref_field_report_dref_event.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.30 on 2026-05-21 04:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0232_remove_event_auto_generated_source_event_source_and_more'), + ('dref', '0088_remove_identifiedneed_title_ar_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='dref', + name='field_report', + ), + migrations.AddField( + model_name='dref', + name='event', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_dref', to='api.event', verbose_name='event'), + ), + ] diff --git a/dref/models.py b/dref/models.py index dc8ae4228..4be868395 100644 --- a/dref/models.py +++ b/dref/models.py @@ -1,5 +1,6 @@ import copy import os +from typing import Optional import reversion from django.conf import settings @@ -11,7 +12,7 @@ from django.utils.translation import gettext_lazy as _ from pdf2image import convert_from_bytes -from api.models import Country, DisasterType, District, FieldReport +from api.models import Country, DisasterType, District, Event from deployments.models import Sector from main.fields import SecureFileField @@ -295,13 +296,13 @@ class Status(models.IntegerChoices): related_name="modified_by_dref", ) users = models.ManyToManyField(settings.AUTH_USER_MODEL, verbose_name=_("users"), blank=True, related_name="user_dref") - field_report = models.ForeignKey( - FieldReport, - verbose_name=_("field report"), + event = models.ForeignKey[Event]( + Event, + verbose_name=_("event"), on_delete=models.SET_NULL, null=True, blank=True, - related_name="field_report_dref", + related_name="event_dref", ) title = models.CharField(verbose_name=_("title"), max_length=255) title_prefix = models.CharField(verbose_name=_("title prefix"), max_length=255, null=True, blank=True) @@ -745,6 +746,11 @@ class Status(models.IntegerChoices): blank=True, ) + # TYPING + id: int + pk: int + event_id: Optional[int] + class Meta: verbose_name = _("dref") verbose_name_plural = _("drefs") diff --git a/dref/serializers.py b/dref/serializers.py index f3801fa33..a7b83ac51 100644 --- a/dref/serializers.py +++ b/dref/serializers.py @@ -5,13 +5,14 @@ from django.conf import settings from django.contrib.auth.models import User from django.db import models, transaction +from django.db.models.query import Prefetch from django.utils import timezone from django.utils.translation import get_language, gettext from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from api.models import Appeal +from api.models import Appeal, Event from api.serializers import ( DisasterTypeSerializer, MiniCountrySerializer, @@ -237,6 +238,7 @@ class Meta: "operational_update_details", "final_report_details", "country", + "event", "country_details", "has_ops_update", "has_final_report", @@ -380,6 +382,14 @@ class Meta: ] +class DrefApproveSerializer(serializers.Serializer): + event = serializers.PrimaryKeyRelatedField( + queryset=Event.objects.all(), + required=False, + allow_null=True, + ) + + class DrefSerializer(NestedUpdateMixin, NestedCreateMixin, ModelSerializer): SUB_TOTAL_COST = 75000 SURGE_DEPLOYMENT_COST = 10000 @@ -2255,3 +2265,256 @@ class Meta(BaseDref3Serializer.Meta): class DrefFinalReport3Serializer(BaseDref3Serializer): class Meta(BaseDref3Serializer.Meta): model = DrefFinalReport + + +# NOTE: This serializer is only used for the emergency page in GO, +# which has a very specific and limited use case. +# It is not intended to be a general-purpose serializer for DREF objects, +# and as such, it does not include all fields or functionality of the other serializers. +# It is designed to provide the necessary data for the emergency page in a format that is easy to consume and display. +class EmergencyDrefFinalReportSerializer(serializers.ModelSerializer): + status_display = serializers.CharField(source="get_status_display", read_only=True) + country_details = MiniCountrySerializer(source="country", read_only=True) + district_details = MiniDistrictSerializer(source="district", read_only=True, many=True) + 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) + disaster_type_details = DisasterTypeSerializer(source="disaster_type", read_only=True) + + class Meta: + model = DrefFinalReport + fields = ( + "id", + "title", + "disaster_type_details", + "cover_image_file", + "status", + "status_display", + "event_scope", + "event_description", + "event_date", + "appeal_code", + "glide_code", + "country_details", + "district_details", + "planned_interventions", + "proposed_action", + # Timeframe of operation + "operation_start_date", + "operation_end_date", + "total_dref_allocation", + "government_requested_assistance", + "num_assisted", + "number_of_people_targeted", + "number_of_people_affected", + "total_targeted_population", + "estimated_number_of_affected_male", + "estimated_number_of_affected_female", + "estimated_number_of_affected_girls_under_18", + "estimated_number_of_affected_boys_under_18", + "assisted_num_of_boys_under_18", + "assisted_num_of_girls_under_18", + "assisted_num_of_men", + "assisted_num_of_women", + "women", + "men", + "boys", + "people_assisted", + "date_of_approval", + "created_at", + "modified_at", + ) + + +class TimelineEmergencyDrefOperationalUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = DrefOperationalUpdate + fields = ( + "id", + "summary_of_change", + "date_of_approval", + "operational_update_number", + "total_targeted_population", + "total_dref_allocation", + ) + + +class EmergencyDrefOperationalUpdateSerializer(serializers.ModelSerializer): + status_display = serializers.CharField(source="get_status_display", read_only=True) + country_details = MiniCountrySerializer(source="country", read_only=True) + district_details = MiniDistrictSerializer(source="district", read_only=True, many=True) + 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) + + class Meta: + model = DrefOperationalUpdate + fields = ( + "id", + "title", + "disaster_type_details", + "cover_image_file", + "status", + "status_display", + "event_scope", + "event_description", + "operational_update_number", + "event_date", + "appeal_code", + "glide_code", + "country_details", + "district_details", + "planned_interventions", + # Timeframe of operation + "new_operational_start_date", + "new_operational_end_date", + "total_dref_allocation", + "government_requested_assistance", + "women", + "men", + "boys", + "people_assisted", + "number_of_people_targeted", + "number_of_people_affected", + "total_targeted_population", + "estimated_number_of_affected_male", + "estimated_number_of_affected_female", + "estimated_number_of_affected_girls_under_18", + "estimated_number_of_affected_boys_under_18", + ) + + +class EmergencyDrefSerializer(serializers.ModelSerializer): + status_display = serializers.CharField(source="get_status_display", read_only=True) + country_details = MiniCountrySerializer(source="country", read_only=True) + 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) + 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) + + # Dref operational update + operational_update_details = serializers.SerializerMethodField() + # Dref Final report + final_report_details = serializers.SerializerMethodField() + + # Timeline of operational updates + timeline_operational_updates = serializers.SerializerMethodField() + + class Meta: + model = Dref + fields = ( + "id", + "title", + "type_of_dref", + "type_of_dref_display", + "disaster_type_details", + "cover_image_file", + "event_description", + "event_scope", + "status", + "status_display", + "appeal_code", + "glide_code", + "country_details", + "district_details", + "planned_interventions", + "proposed_action", + "emergency_appeal_planned", + "government_requested_assistance", + "did_ns_request_fund", + # Related drefs + "operational_update_details", + "final_report_details", + # Key Figures + "num_affected", + "num_assisted", + "women", + "men", + "boys", + "hazard_date_and_location", + "amount_requested", + "total_cost", + "total_targeted_population", + "estimated_number_of_affected_male", + "estimated_number_of_affected_female", + "estimated_number_of_affected_girls_under_18", + "estimated_number_of_affected_boys_under_18", + "people_assisted", + # Operational timeframe date + "hazard_date", + "end_date", + "total_cost", + "date_of_approval", + # For Response Type + "event_date", + "timeline_operational_updates", + ) + + @extend_schema_field(TimelineEmergencyDrefOperationalUpdateSerializer(many=True)) + def get_timeline_operational_updates(self, obj): + ops_updates = DrefOperationalUpdate.objects.filter(dref=obj).order_by("operational_update_number") + serializer = TimelineEmergencyDrefOperationalUpdateSerializer( + ops_updates, + many=True, + ) + return serializer.data + + @extend_schema_field(EmergencyDrefOperationalUpdateSerializer()) + def get_operational_update_details(self, obj): + if not obj.operational_update_id: + return None + + instance = ( + DrefOperationalUpdate.objects.select_related( + "country", + "disaster_type", + "cover_image", + "cover_image__created_by", + ) + .prefetch_related( + "district", + Prefetch( + "planned_interventions", + queryset=PlannedIntervention.objects.prefetch_related("indicators"), + ), + ) + .get(pk=obj.operational_update_id) + ) + + return EmergencyDrefOperationalUpdateSerializer( + instance, + context=self.context, + ).data + + @extend_schema_field(EmergencyDrefFinalReportSerializer()) + def get_final_report_details(self, obj): + if not obj.final_report_id: + return None + + instance = ( + DrefFinalReport.objects.select_related( + "country", + "disaster_type", + "cover_image", + "cover_image__created_by", + ) + .prefetch_related( + "district", + Prefetch( + "planned_interventions", + queryset=PlannedIntervention.objects.prefetch_related("indicators"), + ), + Prefetch( + "proposed_action", + queryset=ProposedAction.objects.prefetch_related("activities"), + ), + ) + .get(pk=obj.final_report_id) + ) + + return EmergencyDrefFinalReportSerializer( + instance, + context=self.context, + ).data diff --git a/dref/utils.py b/dref/utils.py index 70db6822c..15fd74efe 100644 --- a/dref/utils.py +++ b/dref/utils.py @@ -2,6 +2,7 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.db import models +from api.models import Event from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate @@ -53,3 +54,19 @@ def get_dref_users(): ) ) return dref_users_list + + +def create_event_from_dref(dref: Dref) -> Event: + event = Event.objects.create( + name=dref.title, + dtype=dref.disaster_type, + summary=dref.event_description or dref.event_scope or "", + disaster_start_date=dref.event_date or dref.hazard_date, + glide=dref.glide_code or "", + auto_generated=True, + source=Event.EventSource.DREF, + ) + + event.countries.add(dref.country) + event.districts.add(*dref.district.all()) + return event diff --git a/dref/views.py b/dref/views.py index 23bb51e71..507f8508a 100644 --- a/dref/views.py +++ b/dref/views.py @@ -36,6 +36,7 @@ AddDrefUserSerializer, CompletedDrefOperationsSerializer, Dref3Serializer, + DrefApproveSerializer, DrefFileInputSerializer, DrefFileSerializer, DrefFinalReport3Serializer, @@ -48,6 +49,7 @@ MiniDrefSerializer, ) from dref.tasks import process_dref_translation +from dref.utils import create_event_from_dref from main.permissions import DenyGuestUserPermission logger = logging.getLogger(__name__) @@ -88,23 +90,48 @@ def get_queryset(self): ) return filter_dref_queryset_by_user_access(user, queryset) - @extend_schema(request=None, responses=DrefSerializer) + @extend_schema( + request=DrefApproveSerializer, + responses=DrefSerializer, + ) @action( detail=True, url_path="approve", methods=["post"], - permission_classes=[permissions.IsAuthenticated, ApproveDrefPermission, DenyGuestUserPermission], + permission_classes=[ + permissions.IsAuthenticated, + ApproveDrefPermission, + DenyGuestUserPermission, + ], ) def get_approved(self, request, pk=None, version=None): - dref = self.get_object() + dref: Dref = self.get_object() + if dref.status == Dref.Status.APPROVED: raise serializers.ValidationError(gettext("This Dref has already been approved.")) + if dref.status != Dref.Status.FINALIZED: raise serializers.ValidationError(gettext("Must be finalized before it can be approved")) + + serializer = DrefApproveSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + event = serializer.validated_data.get("event", None) + + if dref.event and event and dref.event != event: + raise serializers.ValidationError({"event": gettext("This Dref is already attached to an event.")}) + + # NOTE: If the Dref is not attached to an event, + # attaching it to the provided event or create a new one if not provided. + if not dref.event: + if event: + dref.event = event + else: + dref.event = create_event_from_dref(dref) dref.status = Dref.Status.APPROVED - dref.save(update_fields=["status"]) - serializer = DrefSerializer(dref, context={"request": request}) - return response.Response(serializer.data) + dref.save(update_fields=["event", "status"]) + + return response.Response(DrefSerializer(dref, context={"request": request}).data) @extend_schema(request=None, responses=DrefSerializer) @action( diff --git a/main/urls.py b/main/urls.py index f3a249f3f..7e7821033 100644 --- a/main/urls.py +++ b/main/urls.py @@ -261,6 +261,7 @@ url(r"^api/v2/per-options/", per_views.PerOptionsView.as_view()), url(r"^api/v2/export-per/(?P\d+)/", per_views.ExportPerView.as_view()), url(r"^api/v2/local-units-options/", local_units_views.LocalUnitOptionsView.as_view()), + # NOTE: This should be removed as DefaultRouter should cover this. url(r"^api/v2/event/(?P\d+)", api_views.EventViewset.as_view({"get": "retrieve"})), url(r"^api/v2/event/(?P[-\w]+)", api_views.EventViewset.as_view({"get": "retrieve"}, lookup_field="slug")), url(r"^api/v2/delegation-office/(?P\d+)", DelegationOfficeDetailAPIView.as_view()),