feat(2-G.5): preview_for_settings framework hook + NWIS opt-in

Adds an optional async hook on SourceAdapter so any adapter can surface a
settings-driven preview on its /adapters/<name> edit page. The framework
renders the result generically as a table — no adapter-name branches in
GUI templates or route code.

Framework changes:
- src/central/adapter.py: new async preview_for_settings(self, settings)
  on the base class, default returns None. Adapters opt in by overriding;
  non-overriding adapters render unchanged.
- src/central/gui/routes.py: GET /adapters/{name} instantiates the adapter
  with a no-op _PreviewConfigStore stub and a /dev/null cursor path (GUI
  has no live ConfigStore), constructs settings_obj via the schema, and
  calls preview_for_settings inside a try/except. Result lands in template
  context as preview_rows / preview_error.
- src/central/gui/templates/_adapter_preview.html: new partial. Generic
  table with columns derived from the first dict's keys; error banner
  mirrors the existing last_error article style.
- src/central/gui/templates/adapters_edit.html: one-line include between
  the Region fieldset and Save/Cancel.

NWIS opt-in:
- New NWIS_MONITORING_LOCATIONS_URL constant and _PREVIEW_LIMIT cap of 50.
- preview_for_settings returns None when region is None, otherwise one-shot
  fetches monitoring-locations within the bbox via a fresh aiohttp session.
  Must work even when adapter is not started -- the GUI process never calls
  startup(). Returns list[dict] with the contract column order: site_id,
  name, site_type, state. Errors propagate so the framework can render the
  operator-visible banner.
- HTTP call factored into _fetch_preview_text so tests mock cleanly.

Tests (7 new):
- tests/test_preview_hook.py: default returns None; partial renders list
  with correct headers/rows/count; partial renders error banner; partial
  renders empty when both context values are None.
- tests/test_nwis.py adds TestNWISPreview: returns None without region,
  returns rows with correct column order, propagates HTTP errors.

Verification:
- 457/457 full suite green (was 450; +7 new tests).
- Live /adapters/nwis preview returns 50 rows with the contract keys
  against the current production Iowa bbox.
- /adapters/eonet preview_for_settings returns None via base default --
  proves framework is duck-typed, no NWIS-specific code in framework.
This commit is contained in:
zvx 2026-05-19 17:34:35 +00:00
commit ead6ef8ce1
7 changed files with 319 additions and 1 deletions

View file

@ -78,3 +78,12 @@ class SourceAdapter(ABC):
async def shutdown(self) -> None:
"""Optional lifecycle hook called on graceful shutdown."""
pass
async def preview_for_settings(self, settings: BaseModel) -> list[dict] | None:
"""Optional. Override to surface a settings-driven preview on the edit page.
Return list[dict] (framework renders as a generic table; columns come from
the first dict's keys, in insertion order). Return None to skip preview.
Raise to surface an error banner framework catches at the route boundary.
"""
return None

View file

@ -28,6 +28,12 @@ logger = logging.getLogger(__name__)
NWIS_LATEST_CONTINUOUS_URL = (
"https://api.waterdata.usgs.gov/ogcapi/v0/collections/latest-continuous/items"
)
NWIS_MONITORING_LOCATIONS_URL = (
"https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items"
)
# Per-render cap for the settings-driven preview (PR G.5). Keep small so the
# /adapters/<name> edit page renders quickly.
_PREVIEW_LIMIT = 50
# Single source of truth for the parameter-code default. Operators tune via
# NWISSettings.parameter_codes; do NOT duplicate this list elsewhere
@ -375,3 +381,55 @@ class NWISAdapter(SourceAdapter):
geo=Geo(centroid=centroid),
data=data,
)
async def _fetch_preview_text(self, url: str) -> str:
"""One-shot GET for the preview render.
Uses a fresh aiohttp session preview must work even when the adapter
isn't started (the GUI process never calls startup()). Factored out so
tests can mock the HTTP call without touching aiohttp internals.
"""
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=15),
) as session:
async with session.get(
url, headers={"User-Agent": "Central/0.4"}
) as resp:
resp.raise_for_status()
return await resp.text()
async def preview_for_settings(self, settings: NWISSettings) -> list[dict] | None:
"""Surface monitoring-locations inside the configured bbox.
Returns up to _PREVIEW_LIMIT rows from the monitoring-locations
collection. Returns None if region is unset (no useful preview).
Raises on HTTP / JSON / shape failure framework catches at the route.
"""
if settings.region is None:
return None
params = {
"bbox": (
f"{settings.region.west},{settings.region.south},"
f"{settings.region.east},{settings.region.north}"
),
"limit": str(_PREVIEW_LIMIT),
}
url = f"{NWIS_MONITORING_LOCATIONS_URL}?{urlencode(params)}"
text = await self._fetch_preview_text(url)
page = json.loads(text)
features = page.get("features") or []
rows: list[dict] = []
for feat in features:
props = feat.get("properties") or {}
rows.append(
{
"site_id": feat.get("id"),
"name": props.get("monitoring_location_name"),
"site_type": props.get("site_type_code"),
"state": props.get("state_name"),
}
)
return rows

View file

@ -46,6 +46,9 @@ from central.gui.audit import (
)
from functools import cache
from pathlib import Path
from central.config_models import AdapterConfig
from central.gui.db import get_pool
from central.gui.form_descriptors import describe_fields, FieldDescriptor
from central.adapter_discovery import discover_adapters
@ -55,13 +58,23 @@ from pydantic import ValidationError
@cache
def _adapter_classes() -> dict:
"""Cached adapter class discovery.
GUI is a separate process from supervisor; walks pkgutil itself.
Python's import cache makes subsequent calls free.
"""
return discover_adapters()
class _PreviewConfigStore:
"""No-op stand-in passed to adapter __init__ when calling preview_for_settings.
preview_for_settings implementations must create their own one-shot HTTP
session and must not depend on config_store / cursor_db state the GUI
process has no live ConfigStore (the supervisor owns the real one)."""
pass
router = APIRouter()
# Streams to display on dashboard -- derived from the registry's dashboard flag.
@ -1450,6 +1463,28 @@ async def adapters_edit_form(
)
api_key_missing = not has_key
# Generic settings-driven preview. Adapters opt in by overriding
# SourceAdapter.preview_for_settings; the framework is duck-typed on the
# returned list[dict] shape and never branches on adapter name.
preview_rows: list[dict] | None = None
preview_error: str | None = None
if adapter_cls is not None and hasattr(adapter_cls, "settings_schema"):
try:
settings_obj = adapter_cls.settings_schema(**settings)
preview_cfg = AdapterConfig(
name=row["name"],
enabled=row["enabled"],
cadence_s=row["cadence_s"],
settings=settings,
updated_at=row["updated_at"],
)
preview_adapter = adapter_cls(
preview_cfg, _PreviewConfigStore(), Path("/dev/null")
)
preview_rows = await preview_adapter.preview_for_settings(settings_obj)
except Exception as exc:
preview_error = f"Preview unavailable: {exc}"
csrf_token = request.state.csrf_token
response = templates.TemplateResponse(
request=request,
@ -1466,6 +1501,8 @@ async def adapters_edit_form(
"tile_attribution": tile_attribution,
"api_key_missing": api_key_missing,
"requires_api_key_alias": requires_api_key_alias,
"preview_rows": preview_rows,
"preview_error": preview_error,
},
)
return response

View file

@ -0,0 +1,23 @@
{% if preview_error %}
<article aria-label="Preview Unavailable" style="background-color: var(--pico-del-color); margin-bottom: 1rem;">
<strong>{{ preview_error }}</strong>
</article>
{% elif preview_rows %}
<fieldset>
<legend>Preview ({{ preview_rows|length }} rows)</legend>
<table class="preview-table" role="grid">
<thead>
<tr>
{% for col in preview_rows[0].keys() %}<th>{{ col }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in preview_rows %}
<tr>
{% for col in preview_rows[0].keys() %}<td>{{ row[col] }}</td>{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</fieldset>
{% endif %}

View file

@ -178,6 +178,8 @@
</fieldset>
{% endif %}
{% include "_adapter_preview.html" %}
<button type="submit">Save Changes</button>
<a href="/adapters" role="button" class="outline">Cancel</a>
</form>