Compare commits

..

No commits in common. "main" and "v0.6.3" have entirely different histories.

4 changed files with 31 additions and 144 deletions

View file

@ -1,7 +1,6 @@
"""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
@ -2728,24 +2727,6 @@ 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.
@ -2813,6 +2794,7 @@ 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
@ -2842,23 +2824,17 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
if row["geometry"]: if row["geometry"]:
geometry = json.loads(row["geometry"]) geometry = json.loads(row["geometry"])
event = { events.append({
"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

View file

@ -1,15 +1,12 @@
"""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.adapter_discovery import discover_adapters from central.gui.routes import events_list, events_rows, events_json
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:
@ -686,11 +683,7 @@ 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"
# `subject` is now derived from the inner payload (rendered partial), assert context["events"][0]["subject"] == "M4.2 Earthquake"
# 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 ---------------------------------
@ -1092,106 +1085,3 @@ 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

View file

@ -5,7 +5,7 @@ import base64
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import asyncpg import asyncpg
import pytest import pytest
@ -206,7 +206,10 @@ class TestRateLimitGuarantee:
state.config = new_config state.config = new_config
state.adapter.cadence_s = 90 state.adapter.cadence_s = 90
# Calculate expected next poll time
expected_next_poll = last_poll + timedelta(seconds=90)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expected_wait = max(0, (expected_next_poll - now).total_seconds())
# The wait time should be based on last_poll + new_cadence # The wait time should be based on last_poll + new_cadence
# Since last_poll was 30 seconds ago and new cadence is 90, # Since last_poll was 30 seconds ago and new cadence is 90,
@ -227,6 +230,7 @@ class TestRateLimitGuarantee:
If operator increases cadence to 120s after a gap of 150s, If operator increases cadence to 120s after a gap of 150s,
the poll should happen now (not wait another 120s). the poll should happen now (not wait another 120s).
""" """
from central.supervisor import AdapterState
mock_adapter = MagicMock() mock_adapter = MagicMock()
mock_adapter.name = "test" mock_adapter.name = "test"
@ -244,6 +248,13 @@ class TestRateLimitGuarantee:
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
) )
state = AdapterState(
name="test",
adapter=mock_adapter,
config=config,
last_completed_poll=last_poll,
)
# Calculate next poll time # Calculate next poll time
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
next_poll_at = last_poll.timestamp() + config.cadence_s next_poll_at = last_poll.timestamp() + config.cadence_s
@ -263,6 +274,7 @@ class TestRateLimitGuarantee:
poll should be at (last_completed_poll + cadence_s), not immediately poll should be at (last_completed_poll + cadence_s), not immediately
(unless that time has already passed). (unless that time has already passed).
""" """
from central.supervisor import AdapterState
mock_adapter = MagicMock() mock_adapter = MagicMock()
mock_adapter.name = "test" mock_adapter.name = "test"
@ -281,6 +293,13 @@ class TestRateLimitGuarantee:
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
) )
state = AdapterState(
name="test",
adapter=mock_adapter,
config=config,
last_completed_poll=last_poll,
)
# Calculate next poll time # Calculate next poll time
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
next_poll_at = last_poll.timestamp() + config.cadence_s next_poll_at = last_poll.timestamp() + config.cadence_s

View file

@ -9,13 +9,15 @@ IMPORTANT: These tests are designed to:
- PASS on fixed code (last_completed_poll is preserved across disable/enable) - PASS on fixed code (last_completed_poll is preserved across disable/enable)
""" """
import asyncio
import base64 import base64
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio
from central.config_models import AdapterConfig from central.config_models import AdapterConfig
from central.bootstrap_config import get_settings from central.bootstrap_config import get_settings
@ -193,7 +195,7 @@ class TestEnableDisableEnableIntegration:
- Assert next poll fires immediately (last+cadence is in past) - Assert next poll fires immediately (last+cadence is in past)
- Assert exactly ONE poll happens, not multiple catch-up - Assert exactly ONE poll happens, not multiple catch-up
""" """
from central.supervisor import Supervisor from central.supervisor import Supervisor, AdapterState
config_source = MockConfigSource() config_source = MockConfigSource()
initial_config = AdapterConfig( initial_config = AdapterConfig(
@ -304,7 +306,7 @@ class TestEnableDisableEnableIntegration:
- Re-enable adapter 20 seconds later (still within cadence window) - Re-enable adapter 20 seconds later (still within cadence window)
- Assert next poll fires at last_poll + 60s, NOT immediately - Assert next poll fires at last_poll + 60s, NOT immediately
""" """
from central.supervisor import Supervisor from central.supervisor import Supervisor, AdapterState
config_source = MockConfigSource() config_source = MockConfigSource()
initial_config = AdapterConfig( initial_config = AdapterConfig(