mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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:
parent
1f0e2a091e
commit
ead6ef8ce1
7 changed files with 319 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
src/central/gui/templates/_adapter_preview.html
Normal file
23
src/central/gui/templates/_adapter_preview.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue