From 578c9bc0fe8482e052e344aa2f074defad568f12 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Thu, 21 May 2026 19:07:19 +0000 Subject: [PATCH] feat(events-subject): derive /events.json subject from inner payload The events_json SELECT read payload->>'subject', but the CloudEvents envelope has no top-level subject, so every JSON consumer saw subject: null. The /events GUI already derives readable subjects via per-adapter templates/_event_summaries/{adapter}.html (PR L-c). This makes the JSON path produce the same plain-text subjects with no duplicated logic: _derive_subject(event) renders the same partial the table uses (falling back to _default.html) and html.unescapes the autoescaped output so JSON consumers get plain text (e.g. ">=1 MeV" rather than the escaped ">=1 MeV"). _fetch_events now sets subject from it and drops the always-null SQL expression. The GUI Subject cell is unchanged. Adds TestEventsJsonSubject (parameterized over discover_adapters(), no hardcoded list): non-null subject per adapter, equality with the rendered partial, pinned human text for the deterministic adapters, swpc_alerts truncation, and null fallbacks. Updates one TestEventRowDataAttributes assertion that pinned the old SQL pass-through contract. One route change plus tests; central-gui restart required. Full suite: 629 passed, 1 skipped (central and unprivileged zvx). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/central/gui/routes.py | 32 +++++++- tests/test_events_feed_frontend.py | 114 ++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index a056496..74a6d6b 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -1,6 +1,7 @@ """Route handlers for Central GUI.""" import base64 +import html import json import logging import re @@ -2727,6 +2728,24 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]: }, None +def _derive_subject(event: dict) -> str | None: + """Derive an event's plain-text subject for the JSON API. + + Renders the same per-adapter ``_event_summaries/{adapter}.html`` partial + the /events table uses (falling back to ``_default.html``), so the JSON + subject carries the same human text as the GUI's Subject cell with no + duplicated derivation logic. The partials are HTML-autoescaped for the + table (e.g. ``>`` -> ``>``); we ``html.unescape`` so JSON consumers get + plain text. Returns ``None`` when the partial yields no text -- an unknown + adapter, or an event whose source fields don't support a subject (e.g. a + wfigs row with neither county nor state). + """ + template = _get_templates().env.select_template( + [f"_event_summaries/{event.get('adapter')}.html", "_event_summaries/_default.html"] + ) + return html.unescape(template.render(event=event)).strip() or None + + async def _fetch_events(parsed_params: dict) -> EventsQueryResult: """ Fetch events from database using parsed parameters. @@ -2794,7 +2813,6 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: received, adapter, category, - payload->>'subject' as subject, ST_AsGeoJSON(geom) as geometry, payload as data, regions @@ -2824,17 +2842,23 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: if row["geometry"]: geometry = json.loads(row["geometry"]) - events.append({ + event = { "id": row["id"], "time": row["time"].isoformat(), "received": row["received"].isoformat(), "adapter": row["adapter"], "category": row["category"], - "subject": row["subject"], "geometry": geometry, "data": dict(row["data"]) if row["data"] else {}, "regions": list(row["regions"]) if row["regions"] else [], - }) + } + # Subject is derived from the inner adapter payload by rendering the + # same _event_summaries partial the /events table uses, so the JSON + # `subject` matches the GUI's Subject cell. (The CloudEvents envelope + # has no top-level `subject`; the old `payload->>'subject'` was always + # null for every consumer.) + event["subject"] = _derive_subject(event) + events.append(event) # Build next_cursor if there are more results next_cursor = None diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index a2b557c..f1ab1e7 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -1,12 +1,15 @@ """Tests for events feed frontend routes.""" +import html import json from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest -from central.gui.routes import events_list, events_rows, events_json +from central.adapter_discovery import discover_adapters +from central.gui import templates as _gui_templates +from central.gui.routes import events_list, events_rows, events_json, _derive_subject class TestEventsFeedFrontendAuthenticated: @@ -683,7 +686,11 @@ class TestEventRowDataAttributes: assert len(context["events"]) == 1 assert context["events"][0]["adapter"] == "usgs_quake" assert context["events"][0]["category"] == "quake.event" - assert context["events"][0]["subject"] == "M4.2 Earthquake" + # `subject` is now derived from the inner payload (rendered partial), + # not a DB pass-through, so the mock's input value is no longer echoed; + # just confirm the field is present. See TestEventsJsonSubject for the + # derivation contract. + assert "subject" in context["events"][0] # --- PR L-b: operator /events tab polish --------------------------------- @@ -1085,3 +1092,106 @@ class TestTableRendersThroughHTTP: cells = _first_row_cells(resp.text) assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty assert cells[4] == self._expected_adapter_display() # Adapter display_name + + +# --- feat(events-json-subject): JSON subject derivation ------------------ + +# Representative inner adapter payloads (payload->'data'->'data'), captured from +# production -- one per registered adapter. Keyed by adapter name so the +# coverage test below fails loudly if a new adapter ships without a sample. +_SAMPLE_INNER = { + "eonet": {"title": "Kress Wildfire, Swisher, Texas"}, + "firms": {"frp": 0.34, "confidence": "nominal"}, + "gdacs": {"title": "Green flood alert in Austria", "alertlevel": "Green"}, + "inciweb": {"title": "MTHLF Jericho Creek"}, + "nwis": {"value": 93.2, "unit_of_measure": "ft^3/s"}, + "nws": {"event": "Special Weather Statement", "severity": "Moderate"}, + "swpc_alerts": { + "product_id": "EF3A", + "message": ( + "Space Weather Message Code: ALTEF3\r\nSerial Number: 3691\r\n" + "Issue Time: 2026 May 21 0509 UTC\r\n\r\nCONTINUED ALERT: " + "Electron 2MeV Integral Flux exceeded 1000pfu" + ), + }, + "swpc_kindex": {"Kp": 1.0}, + "swpc_protons": {"flux": 15.06399917602539, "energy": ">=1 MeV"}, + "usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"}, + "wfigs_incidents": {"county": "Montezuma", "state": "CO"}, + "wfigs_perimeters": {"county": "Carbon", "state": "MT"}, +} + +# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted +# (its message runs through Jinja's truncate(80)) and is checked separately. +# swpc_protons expects unescaped ">=" -- _derive_subject html.unescapes the +# autoescaped partial output so JSON consumers get plain text. +_EXPECTED_SUBJECT = { + "eonet": "Kress Wildfire, Swisher, Texas", + "firms": "Fire detected — 0.34 MW radiative power", + "gdacs": "Green flood alert in Austria — Green alert", + "inciweb": "MTHLF Jericho Creek", + "nwis": "Water reading: 93.2 ft^3/s", + "nws": "Special Weather Statement — Moderate", + "swpc_kindex": "Geomagnetic activity (Kp index): 1.0", + "swpc_protons": "Solar proton flux: 15.06 pfu at >=1 MeV", + "usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA", + "wfigs_incidents": "Wildfire incident — Montezuma, CO", + "wfigs_perimeters": "Wildfire perimeter — Carbon, MT", +} + + +def _subject_event(adapter: str, inner: dict) -> dict: + """Build a minimal event dict shaped like _fetch_events output.""" + return {"adapter": adapter, "data": {"data": {"data": inner}}} + + +class TestEventsJsonSubject: + """/events.json `subject` is derived from the inner payload and carries the + same human text as the GUI's per-adapter Subject cell (feat/events-json-subject). + + The old `payload->>'subject'` SQL was always null (the CloudEvents envelope + has no top-level subject). Parameterized over discover_adapters() -- no + hardcoded adapter list. + """ + + def test_sample_covers_every_registered_adapter(self): + """No hardcoded list: samples must track the live registry exactly.""" + assert set(_SAMPLE_INNER) == set(discover_adapters()) + + @pytest.mark.parametrize("adapter", sorted(discover_adapters())) + def test_subject_non_null_per_adapter(self, adapter): + """Every registered adapter derives a non-null subject for a real event.""" + event = _subject_event(adapter, _SAMPLE_INNER[adapter]) + assert _derive_subject(event) is not None + + @pytest.mark.parametrize("adapter", sorted(discover_adapters())) + def test_subject_matches_rendered_partial(self, adapter): + """Derived subject equals the adapter's own partial (unescaped) -- the + JSON path and the GUI Subject cell never diverge.""" + event = _subject_event(adapter, _SAMPLE_INNER[adapter]) + oracle = html.unescape( + _gui_templates.env.get_template(f"_event_summaries/{adapter}.html").render(event=event) + ).strip() + assert _derive_subject(event) == oracle + + @pytest.mark.parametrize("adapter", sorted(_EXPECTED_SUBJECT)) + def test_subject_exact_human_text(self, adapter): + """Pin the human-readable subject for the deterministic adapters.""" + event = _subject_event(adapter, _SAMPLE_INNER[adapter]) + assert _derive_subject(event) == _EXPECTED_SUBJECT[adapter] + + def test_swpc_alerts_prefixes_id_and_truncates_message(self): + """swpc_alerts subject prefixes the product id and truncates the body.""" + event = _subject_event("swpc_alerts", _SAMPLE_INNER["swpc_alerts"]) + subject = _derive_subject(event) + assert subject is not None + assert subject.startswith("Space weather alert EF3A: ") + assert subject.endswith("...") + + def test_unknown_adapter_yields_none(self): + """Unknown adapters fall back to _default.html -> no subject.""" + assert _derive_subject(_subject_event("does_not_exist", {"x": 1})) is None + + def test_missing_source_fields_yields_none(self): + """An event lacking its adapter's source fields derives no subject.""" + assert _derive_subject(_subject_event("usgs_quake", {})) is None