mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 02:24:38 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
d0375225b2
commit
578c9bc0fe
2 changed files with 140 additions and 6 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
"""Route handlers for Central GUI."""
|
"""Route handlers for Central GUI."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -2727,6 +2728,24 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
|
||||||
}, 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:
|
async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
"""
|
"""
|
||||||
Fetch events from database using parsed parameters.
|
Fetch events from database using parsed parameters.
|
||||||
|
|
@ -2794,7 +2813,6 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
received,
|
received,
|
||||||
adapter,
|
adapter,
|
||||||
category,
|
category,
|
||||||
payload->>'subject' as subject,
|
|
||||||
ST_AsGeoJSON(geom) as geometry,
|
ST_AsGeoJSON(geom) as geometry,
|
||||||
payload as data,
|
payload as data,
|
||||||
regions
|
regions
|
||||||
|
|
@ -2824,17 +2842,23 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
if row["geometry"]:
|
if row["geometry"]:
|
||||||
geometry = json.loads(row["geometry"])
|
geometry = json.loads(row["geometry"])
|
||||||
|
|
||||||
events.append({
|
event = {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"time": row["time"].isoformat(),
|
"time": row["time"].isoformat(),
|
||||||
"received": row["received"].isoformat(),
|
"received": row["received"].isoformat(),
|
||||||
"adapter": row["adapter"],
|
"adapter": row["adapter"],
|
||||||
"category": row["category"],
|
"category": row["category"],
|
||||||
"subject": row["subject"],
|
|
||||||
"geometry": geometry,
|
"geometry": geometry,
|
||||||
"data": dict(row["data"]) if row["data"] else {},
|
"data": dict(row["data"]) if row["data"] else {},
|
||||||
"regions": list(row["regions"]) if row["regions"] 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
|
# Build next_cursor if there are more results
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""Tests for events feed frontend routes."""
|
"""Tests for events feed frontend routes."""
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
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:
|
class TestEventsFeedFrontendAuthenticated:
|
||||||
|
|
@ -683,7 +686,11 @@ class TestEventRowDataAttributes:
|
||||||
assert len(context["events"]) == 1
|
assert len(context["events"]) == 1
|
||||||
assert context["events"][0]["adapter"] == "usgs_quake"
|
assert context["events"][0]["adapter"] == "usgs_quake"
|
||||||
assert context["events"][0]["category"] == "quake.event"
|
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 ---------------------------------
|
# --- PR L-b: operator /events tab polish ---------------------------------
|
||||||
|
|
@ -1085,3 +1092,106 @@ class TestTableRendersThroughHTTP:
|
||||||
cells = _first_row_cells(resp.text)
|
cells = _first_row_cells(resp.text)
|
||||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue