diff --git a/api/test_views.py b/api/test_views.py index a64f8ba0b..a9c88b3df 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -1606,6 +1606,7 @@ def test_dref_summary_fields_present_when_summary_exists(self): dref=dref, status=DrefSummary.SummaryStatus.SUCCESS, situational_overview="overview text", + needs_identified="needs text", operational_strategy="strategy text", people_centered_approach="approach text", challenges_identified="challenges text", @@ -1619,6 +1620,7 @@ def test_dref_summary_fields_present_when_summary_exists(self): self.assertIsNotNone(summary) self.assertEqual(summary["status"], DrefSummary.SummaryStatus.SUCCESS) self.assertEqual(summary["situational_overview"], "overview text") + self.assertEqual(summary["needs_identified"], "needs text") self.assertEqual(summary["operational_strategy"], "strategy text") self.assertEqual(summary["people_centered_approach"], "approach text") self.assertEqual(summary["challenges_identified"], "challenges text") diff --git a/assets b/assets index aabf2e6d9..02256c550 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit aabf2e6d979807e9741470b4d065b5450b4fe1fd +Subproject commit 02256c550913cccabd9102e19f8d32059cba93b5 diff --git a/dref/admin.py b/dref/admin.py index 6e618ceb2..609bea476 100644 --- a/dref/admin.py +++ b/dref/admin.py @@ -140,6 +140,7 @@ class DrefSummaryInline(admin.StackedInline, TranslationInlineModelAdmin): "status", "source_hash", "situational_overview", + "needs_identified", "operational_strategy", "people_centered_approach", "challenges_identified", @@ -349,6 +350,7 @@ class DrefSummaryAdmin(TranslationAdmin, admin.ModelAdmin): "status", "source_hash", "situational_overview", + "needs_identified", "operational_strategy", "people_centered_approach", "challenges_identified", diff --git a/dref/factories/dref.py b/dref/factories/dref.py index 4cc61d27a..cfccdd9e3 100644 --- a/dref/factories/dref.py +++ b/dref/factories/dref.py @@ -211,6 +211,7 @@ class Meta: source_id = factory.SelfAttribute("dref.id") status = DrefSummary.SummaryStatus.SUCCESS situational_overview = fuzzy.FuzzyText(length=100) + needs_identified = fuzzy.FuzzyText(length=100) operational_strategy = fuzzy.FuzzyText(length=100) people_centered_approach = fuzzy.FuzzyText(length=100) challenges_identified = fuzzy.FuzzyText(length=100) diff --git a/dref/migrations/0091_drefsummary_needs_identified_and_more.py b/dref/migrations/0091_drefsummary_needs_identified_and_more.py new file mode 100644 index 000000000..b1dd5caf9 --- /dev/null +++ b/dref/migrations/0091_drefsummary_needs_identified_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.16 on 2026-08-14 06:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dref', '0090_drefsummary'), + ] + + operations = [ + migrations.AddField( + model_name='drefsummary', + name='needs_identified', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='drefsummary', + name='needs_identified_ar', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='drefsummary', + name='needs_identified_en', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='drefsummary', + name='needs_identified_es', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='drefsummary', + name='needs_identified_fr', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/dref/models.py b/dref/models.py index 84f63b7fd..c36e6c674 100644 --- a/dref/models.py +++ b/dref/models.py @@ -1750,6 +1750,11 @@ class SourceModel(models.IntegerChoices): null=True, ) + needs_identified = models.TextField( + blank=True, + null=True, + ) + operational_strategy = models.TextField( blank=True, null=True, diff --git a/dref/serializers.py b/dref/serializers.py index b016ac67d..0bedb1fa7 100644 --- a/dref/serializers.py +++ b/dref/serializers.py @@ -2454,6 +2454,7 @@ class Meta: "source_display", "source_id", "situational_overview", + "needs_identified", "operational_strategy", "people_centered_approach", "challenges_identified", diff --git a/dref/summary.py b/dref/summary.py index 4976ddece..ee90751a0 100644 --- a/dref/summary.py +++ b/dref/summary.py @@ -34,6 +34,7 @@ # DrefSummary fields — order is the iteration order for prompt assembly. SUMMARY_FIELDS: List[str] = [ "situational_overview", + "needs_identified", "operational_strategy", "people_centered_approach", "challenges_identified", @@ -60,6 +61,11 @@ def _build_situational_overview_prompt(**kwargs) -> str: return f'Data for "situational_overview" — the disaster situation and rationale for the operation:\n{data_json}' +def _build_needs_identified_prompt(**kwargs) -> str: + data_json = _section_data_json(kwargs) + return f'Data for "needs_identified" — the needs and gaps identified for the affected population:\n{data_json}' + + def _build_operational_strategy_prompt(**kwargs) -> str: data_json = _section_data_json(kwargs) return f'Data for "operational_strategy" — the objective and strategy of the response:\n{data_json}' @@ -83,6 +89,7 @@ def _build_lessons_learned_prompt(**kwargs) -> str: # Registry SECTION_PROMPT_BUILDERS: Dict[str, Callable[..., str]] = { "situational_overview": _build_situational_overview_prompt, + "needs_identified": _build_needs_identified_prompt, "operational_strategy": _build_operational_strategy_prompt, "people_centered_approach": _build_people_centered_approach_prompt, "challenges_identified": _build_challenges_identified_prompt, @@ -90,12 +97,14 @@ def _build_lessons_learned_prompt(**kwargs) -> str: } GLOBAL_PROMPT = ( - "The DREF data above is organised by summary section. Using ONLY that data, write five concise " - "summary sections. Return a single JSON object (and nothing else) with exactly these keys, each " - "summarising the block of the same name:\n" + "The DREF data above is organised by summary section. Using ONLY that data, write six " + "summary sections. Return a single JSON object (and nothing else) with exactly these " + "keys, each summarising the block of the same name:\n" "\n" ' "situational_overview": The disaster situation and the rationale for the operation. Use the ' 'data under the "situational_overview" key.\n' + ' "needs_identified": The needs identified per sector, and any gaps or limitations in the ' + 'assessment. Use the data under the "needs_identified" key.\n' ' "operational_strategy": The overall objective and strategic approach of the response. Use the ' 'data under the "operational_strategy" key.\n' ' "people_centered_approach": Who is targeted and how they are selected and engaged. Use the ' @@ -138,6 +147,9 @@ def _extract_fields(obj, field_names: List[str]) -> dict: SITUATIONAL_COMMON_FIELDS: List[str] = ["event_description", "event_scope"] +# Imminent DREF applications created on the v2 use hazard_date_and_location. +IMMINENT_SITUATIONAL_FIELDS: List[str] = ["hazard_date_and_location"] + OPERATIONAL_COMMON_FIELDS: List[str] = ["operation_objective", "response_strategy"] PEOPLE_COMMON_FIELDS: List[str] = ["people_assisted", "selection_criteria"] @@ -151,14 +163,32 @@ def __init__(self): @staticmethod def _situational_overview_kwargs(source_doc) -> dict: - """Build situational_overview kwargs — common across all document types. + """Imminent v2 applications describe the situation in the scenario analysis fields; others use the common ones.""" + if isinstance(source_doc, Dref) and source_doc.type_of_dref == Dref.DrefType.IMMINENT and source_doc.is_dref_imminent_v2: + return _extract_fields(source_doc, IMMINENT_SITUATIONAL_FIELDS) + return _extract_fields(source_doc, SITUATIONAL_COMMON_FIELDS) # event_scope is empty for Assessment; dropped - ``event_scope`` is one of the common fields; when it is empty (e.g. an - Imminent DREF Application where the scope is not yet known) - ``_extract_fields`` drops it automatically, while by the Final Report - stage the event has materialized and the field feeds the summary. - """ - return _extract_fields(source_doc, SITUATIONAL_COMMON_FIELDS) + @staticmethod + def _needs_identified_kwargs(source_doc) -> dict: + """Collect the needs from the ``needs_identified`` M2M, plus the gaps text.""" + + def need_title(need): + display = getattr(need, "get_title_display", None) + return display() if callable(display) else need.title + + # Order explicitly: needs_identified has no Meta.ordering, so an unordered + # .all() can return rows in different orders across queries, which would + # change the source hash and trigger needless regeneration. + needs = sorted(source_doc.needs_identified.all(), key=lambda need: need.id) + return { + # A need with no description is still meaningful: it names a sector + # where a need was identified, so keep it and drop the empty text. + "needs_identified": [ + {"title": need_title(need), **({"description": need.description} if need.description else {})} for need in needs + ] + or None, + "identified_gaps": _field_val(source_doc, "identified_gaps"), + } @staticmethod def _challenges_and_lessons_kwargs(source_doc) -> Dict[str, dict]: @@ -173,7 +203,7 @@ def pi_title(pi): # Order explicitly: planned_interventions has no Meta.ordering, so an # unordered .all() can return rows in different orders across queries, # which would change the source hash and trigger needless regeneration. - planned = list(source_doc.planned_interventions.order_by("id")) + planned = sorted(source_doc.planned_interventions.all(), key=lambda pi: pi.id) return { "challenges_identified": { "planned_interventions": [{"title": pi_title(pi), "challenges": pi.challenges} for pi in planned if pi.challenges] @@ -189,32 +219,34 @@ def pi_title(pi): @classmethod def _extract_dref_kwargs(cls, dref) -> Dict[str, dict]: - """Dref Application / Assessment / Imminent — three sections only. + """Dref Application / Assessment / Imminent — four sections. Challenges and lessons are not applicable at the application stage; they are formally recorded only in the Final Report. """ return { "situational_overview": cls._situational_overview_kwargs(dref), + "needs_identified": cls._needs_identified_kwargs(dref), "operational_strategy": _extract_fields(dref, OPERATIONAL_COMMON_FIELDS), "people_centered_approach": _extract_fields(dref, PEOPLE_COMMON_FIELDS), } @classmethod def _extract_dref_ops_kwargs(cls, ops) -> Dict[str, dict]: - """DrefOperationalUpdate — three sections. + """DrefOperationalUpdate — four sections. Challenges and lessons are not generated for Operational Updates. """ return { "situational_overview": cls._situational_overview_kwargs(ops), + "needs_identified": cls._needs_identified_kwargs(ops), "operational_strategy": _extract_fields(ops, OPERATIONAL_COMMON_FIELDS), "people_centered_approach": _extract_fields(ops, PEOPLE_COMMON_FIELDS), } @classmethod def _extract_dref_final_kwargs(cls, final) -> Dict[str, dict]: - """DrefFinalReport — all five sections. + """DrefFinalReport — all six sections. Challenges and lessons come from ``planned_interventions`` M2M via ``_challenges_and_lessons_kwargs``; this is the only document type @@ -222,6 +254,7 @@ def _extract_dref_final_kwargs(cls, final) -> Dict[str, dict]: """ kwargs = { "situational_overview": cls._situational_overview_kwargs(final), + "needs_identified": cls._needs_identified_kwargs(final), "operational_strategy": _extract_fields(final, OPERATIONAL_COMMON_FIELDS), "people_centered_approach": _extract_fields(final, PEOPLE_COMMON_FIELDS), } @@ -251,7 +284,7 @@ def get_latest_approved_source(dref: Dref) -> Optional[tuple[DrefSummary.SourceM Priority: Final Report > latest Operational Update > Dref itself. """ final_report = ( - DrefFinalReport.objects.select_related("country", "disaster_type") + DrefFinalReport.objects.prefetch_related("needs_identified", "planned_interventions") .filter(dref=dref, status=Dref.Status.APPROVED) .order_by("-created_at") .first() @@ -260,7 +293,7 @@ def get_latest_approved_source(dref: Dref) -> Optional[tuple[DrefSummary.SourceM return SOURCE_BY_MODEL[DrefFinalReport], final_report latest_ops_update = ( - DrefOperationalUpdate.objects.select_related("country", "disaster_type") + DrefOperationalUpdate.objects.prefetch_related("needs_identified") .filter(dref=dref, status=Dref.Status.APPROVED) .order_by(F("operational_update_number").desc(nulls_last=True), "-created_at") .first() diff --git a/dref/tasks.py b/dref/tasks.py index dc3e74443..e38caca45 100644 --- a/dref/tasks.py +++ b/dref/tasks.py @@ -91,7 +91,8 @@ def generate_dref_summary(dref_id: int, overwrite: bool = False) -> DrefSummaryG specific source passed in, so it self-corrects no matter which approval triggered it or the order concurrent runs execute in. """ - dref = Dref.objects.filter(id=dref_id).first() + # needs_identified feeds the summary when the Dref itself is the latest approved source. + dref = Dref.objects.prefetch_related("needs_identified").filter(id=dref_id).first() if not dref: logger.error("Dref not found for summary generation", extra=logger_context({"dref_id": dref_id})) return DrefSummaryGenerationResult.SOURCE_NOT_FOUND diff --git a/dref/test_summary.py b/dref/test_summary.py index 962c72a41..7624ecf90 100644 --- a/dref/test_summary.py +++ b/dref/test_summary.py @@ -6,9 +6,10 @@ DrefFactory, DrefFinalReportFactory, DrefOperationalUpdateFactory, + IdentifiedNeedFactory, PlannedInterventionFactory, ) -from dref.models import Dref, DrefSummary +from dref.models import Dref, DrefSummary, IdentifiedNeed from dref.summary import SUMMARY_FIELDS, DrefSummaryGenerator from dref.tasks import DrefSummaryGenerationResult, generate_dref_summary from main.llm import ( @@ -80,11 +81,21 @@ def test_get_section_kwargs_for_dref(self): people_in_need=8000, people_assisted="5000 people", selection_criteria="Most vulnerable households", + identified_gaps="No data for the eastern districts", + needs_identified=[ + IdentifiedNeedFactory.create( + title=IdentifiedNeed.Title.SHELTER_HOUSING_AND_SETTLEMENTS, + description="2000 households need emergency shelter", + ) + ], ) kwargs = DrefSummaryGenerator.get_section_kwargs(dref) - self.assertEqual(set(kwargs.keys()), {"situational_overview", "operational_strategy", "people_centered_approach"}) + self.assertEqual( + set(kwargs.keys()), + {"situational_overview", "needs_identified", "operational_strategy", "people_centered_approach"}, + ) # Only the mapped (Figma) fields feed each section — no title/demographics/budget metadata. self.assertEqual(kwargs["situational_overview"]["event_description"], "Severe flooding") self.assertEqual(kwargs["situational_overview"]["event_scope"], "Affected 3 districts") @@ -97,6 +108,31 @@ def test_get_section_kwargs_for_dref(self): self.assertEqual(kwargs["people_centered_approach"]["selection_criteria"], "Most vulnerable households") self.assertNotIn("women", kwargs["people_centered_approach"]) + needs = kwargs["needs_identified"]["needs_identified"] + self.assertEqual(len(needs), 1) + self.assertEqual(needs[0]["title"], "Shelter Housing And Settlements") + self.assertEqual(needs[0]["description"], "2000 households need emergency shelter") + self.assertEqual(kwargs["needs_identified"]["identified_gaps"], "No data for the eastern districts") + + def test_get_section_kwargs_keeps_needs_without_description_and_drops_missing_gaps(self): + # A need with no description still names the sector where a need exists, so it + # is kept; identified_gaps is absent on the Final Report model entirely. + dref = DrefFactory.create( + type_of_dref=Dref.DrefType.RESPONSE, + identified_gaps="", + needs_identified=[IdentifiedNeedFactory.create(title=IdentifiedNeed.Title.HEALTH, description="")], + ) + needs_section = DrefSummaryGenerator.get_section_kwargs(dref)["needs_identified"] + self.assertEqual(needs_section["needs_identified"], [{"title": "Health"}]) + self.assertIsNone(needs_section["identified_gaps"]) + + final_report = DrefFinalReportFactory.create( + needs_identified=[IdentifiedNeedFactory.create(title=IdentifiedNeed.Title.EDUCATION, description="Schools closed")] + ) + final_needs_section = DrefSummaryGenerator.get_section_kwargs(final_report)["needs_identified"] + self.assertEqual(final_needs_section["needs_identified"], [{"title": "Education", "description": "Schools closed"}]) + self.assertIsNone(final_needs_section["identified_gaps"]) + def test_get_section_kwargs_for_dref_operational_update(self): ops_update = DrefOperationalUpdateFactory.create( dref=DrefFactory.create(), @@ -113,11 +149,23 @@ def test_get_section_kwargs_for_dref_operational_update(self): boys=600, new_operational_end_date=date(2025, 6, 1), total_operation_timeframe=6, + identified_gaps="Assessment pending in two districts", + needs_identified=[ + IdentifiedNeedFactory.create( + title=IdentifiedNeed.Title.HEALTH, + description="Mobile clinics still required", + ) + ], ) kwargs = DrefSummaryGenerator.get_section_kwargs(ops_update) - self.assertEqual(set(kwargs.keys()), {"situational_overview", "operational_strategy", "people_centered_approach"}) + self.assertEqual( + set(kwargs.keys()), + {"situational_overview", "needs_identified", "operational_strategy", "people_centered_approach"}, + ) + self.assertEqual(kwargs["needs_identified"]["needs_identified"][0]["description"], "Mobile clinics still required") + self.assertEqual(kwargs["needs_identified"]["identified_gaps"], "Assessment pending in two districts") self.assertEqual(kwargs["situational_overview"]["event_description"], "Flooding continues") self.assertEqual(kwargs["operational_strategy"]["operation_objective"], "Extend shelter support") self.assertEqual(kwargs["operational_strategy"]["response_strategy"], "Extended cash support") @@ -154,6 +202,7 @@ def test_get_section_kwargs_for_dref_final_report(self): set(kwargs.keys()), { "situational_overview", + "needs_identified", "operational_strategy", "people_centered_approach", "challenges_identified", @@ -193,6 +242,52 @@ def test_get_section_kwargs_keeps_event_scope_for_imminent_final_report(self): situational = DrefSummaryGenerator.get_section_kwargs(imminent_final)["situational_overview"] self.assertEqual(situational["event_scope"], "Two districts flooded, 5000 people displaced") + def test_get_section_kwargs_uses_scenario_analysis_for_imminent_v2_application(self): + # An imminent v2 application has no event yet: the situation is described in + # hazard_date_and_location, not event_description/event_scope. + imminent_v2 = DrefFactory.create( + type_of_dref=Dref.DrefType.IMMINENT, + is_dref_imminent_v2=True, + hazard_date_and_location="Cyclone landfall expected 12-14 March in Sofala province", + event_description="Should not be used", + event_scope="Should not be used", + ) + situational = DrefSummaryGenerator.get_section_kwargs(imminent_v2)["situational_overview"] + self.assertEqual( + situational, + {"hazard_date_and_location": "Cyclone landfall expected 12-14 March in Sofala province"}, + ) + + # The flag alone does not switch fields — an imminent DREF that is not v2, and + # a v2 flag on any other type, both keep the common fields. + old_imminent = DrefFactory.create( + type_of_dref=Dref.DrefType.IMMINENT, + hazard_date_and_location="Ignored here", + event_description="Cyclone approaching", + ) + self.assertEqual( + DrefSummaryGenerator.get_section_kwargs(old_imminent)["situational_overview"]["event_description"], + "Cyclone approaching", + ) + + def test_get_section_kwargs_for_imminent_v2_follow_up_uses_common_fields(self): + # hazard_date_and_location lives on Dref alone, so a follow-up document of an + # imminent v2 DREF has nothing to read it from and uses the common fields. + dref = DrefFactory.create( + type_of_dref=Dref.DrefType.IMMINENT, + is_dref_imminent_v2=True, + hazard_date_and_location="Cyclone landfall expected 12-14 March", + ) + ops_update = DrefOperationalUpdateFactory.create( + dref=dref, + type_of_dref=Dref.DrefType.IMMINENT, + event_description="Cyclone made landfall on 13 March", + ) + + situational = DrefSummaryGenerator.get_section_kwargs(ops_update)["situational_overview"] + self.assertEqual(situational["event_description"], "Cyclone made landfall on 13 March") + self.assertNotIn("hazard_date_and_location", situational) + def test_compute_source_hash_changes_with_content_and_is_deterministic(self): dref = DrefFactory.create(event_description="Original description", type_of_dref=Dref.DrefType.RESPONSE) diff --git a/dref/translation.py b/dref/translation.py index 58a7fc986..7fb3f926f 100644 --- a/dref/translation.py +++ b/dref/translation.py @@ -121,6 +121,7 @@ class DrefOperationalUpdateTO(TranslationOptions): class DrefSummaryTO(TranslationOptions): fields = ( "situational_overview", + "needs_identified", "operational_strategy", "people_centered_approach", "challenges_identified", diff --git a/local_units/test_views.py b/local_units/test_views.py index 31b48d755..1994a0414 100644 --- a/local_units/test_views.py +++ b/local_units/test_views.py @@ -1052,6 +1052,7 @@ def setUp(self): self.country2 = CountryFactory.create( name="India", iso3="IND", + iso="IN", region=self.region, is_deprecated=False, independent=True,