diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 7673e21..74a6d6b 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -1,10 +1,11 @@ """Route handlers for Central GUI.""" import base64 +import html import json import logging import re -from datetime import datetime +from datetime import datetime, timezone from typing import Any logger = logging.getLogger("central.gui.routes") @@ -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 @@ -2870,6 +2894,31 @@ def _geometry_summary(geometry: dict | None) -> str: return geom_type +def _format_event_time(iso: str | None) -> str: + """Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds).""" + if not iso: + return "" + try: + dt = datetime.fromisoformat(iso).astimezone(timezone.utc) + except (ValueError, TypeError): + return iso + return dt.strftime("%m-%d-%Y %H:%M") + " UTC" + + +def _decorate_table_events(events: list[dict]) -> None: + """Add display-only fields used by the HTML events table (in place). + + These are for the table chrome only and are deliberately NOT added in + _fetch_events, so the /events.json payload is unchanged. adapter_display + is sourced from the registry (display_name), with the bare name as fallback. + """ + display = {cls.name: cls.display_name for cls in discover_adapters().values()} + for event in events: + event["geometry_summary"] = _geometry_summary(event.get("geometry")) + event["time_human"] = _format_event_time(event.get("time")) + event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter")) + + @router.get("/events.json") async def events_json(request: Request): @@ -2958,9 +3007,8 @@ async def events_list(request: Request) -> HTMLResponse: events = result.events next_cursor = result.next_cursor - # Add geometry summary to each event - for event in events: - event["geometry_summary"] = _geometry_summary(event.get("geometry")) + # Add table-only display fields (time_human, adapter_display, geometry_summary) + _decorate_table_events(events) # Registry-derived adapter list for the filter