From dec8ce8545646c742eab8e51988b99db595eb7b7 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Sun, 17 May 2026 21:14:49 +0000 Subject: [PATCH 1/3] feat(gui): add adapters list and edit UI (1b-4) - Add GET /adapters route for listing all adapters - Add GET /adapters/{name} for edit form with per-adapter fields - Add POST /adapters/{name} for validation, update, and audit - Add ADAPTER_UPDATE audit constant - Add Adapters nav link to base.html - Server-side validation for cadence (60-3600), email format, api_key_alias existence, satellites, and feed values - Region displayed read-only with 1b-5 placeholder - Hot reload via existing NOTIFY trigger (no new mechanism) - Add comprehensive tests (9 tests) Co-Authored-By: Claude Opus 4.5 --- src/central/gui/audit.py | 12 +- src/central/gui/routes.py | 312 +++++++++++++- src/central/gui/templates/adapters_edit.html | 49 +++ .../gui/templates/adapters_edit_firms.html | 21 + .../gui/templates/adapters_edit_nws.html | 5 + .../templates/adapters_edit_usgs_quake.html | 9 + src/central/gui/templates/adapters_list.html | 29 ++ src/central/gui/templates/base.html | 2 + tests/test_adapters.py | 403 ++++++++++++++++++ 9 files changed, 834 insertions(+), 8 deletions(-) create mode 100644 src/central/gui/templates/adapters_edit.html create mode 100644 src/central/gui/templates/adapters_edit_firms.html create mode 100644 src/central/gui/templates/adapters_edit_nws.html create mode 100644 src/central/gui/templates/adapters_edit_usgs_quake.html create mode 100644 src/central/gui/templates/adapters_list.html create mode 100644 tests/test_adapters.py diff --git a/src/central/gui/audit.py b/src/central/gui/audit.py index 428275a..7d2f8f1 100644 --- a/src/central/gui/audit.py +++ b/src/central/gui/audit.py @@ -9,6 +9,7 @@ AUTH_LOGIN_FAILED = "auth.login_failed" AUTH_LOGOUT = "auth.logout" AUTH_PASSWORD_CHANGE = "auth.password_change" OPERATOR_CREATE = "operator.create" +ADAPTER_UPDATE = "adapter.update" async def write_audit( @@ -20,18 +21,15 @@ async def write_audit( after: dict[str, Any] | None = None, ) -> None: """Write an audit log entry.""" - # Serialize before/after as JSON strings if provided - before_json = json.dumps(before) if before else None - after_json = json.dumps(after) if after else None - + # asyncpg handles dict -> jsonb conversion automatically await conn.execute( """ INSERT INTO config.audit_log (operator_id, action, target, before, after) - VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) + VALUES ($1, $2, $3, $4, $5) """, operator_id, action, target, - before_json, - after_json, + before, + after, ) diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index f78b1cd..c27e235 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -1,5 +1,9 @@ """Route handlers for Central GUI.""" +import json +import re +from typing import Any + from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi_csrf_protect import CsrfProtect @@ -12,6 +16,7 @@ from central.gui.auth import ( verify_password, ) from central.gui.audit import ( + ADAPTER_UPDATE, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT, @@ -26,6 +31,21 @@ router = APIRouter() # Streams to display on dashboard DASHBOARD_STREAMS = ["CENTRAL_WX", "CENTRAL_FIRE", "CENTRAL_QUAKE", "CENTRAL_META"] +# Email validation regex (simple but effective) +EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + + +def _get_valid_satellites() -> list[str]: + """Get valid satellite identifiers from firms adapter.""" + from central.adapters.firms import SATELLITE_SHORT + return list(SATELLITE_SHORT.keys()) + + +def _get_valid_feeds() -> set[str]: + """Get valid feed values from usgs_quake adapter.""" + from central.adapters.usgs_quake import VALID_FEEDS + return VALID_FEEDS + def _get_templates(): """Get templates instance (deferred import to avoid circular).""" @@ -199,7 +219,6 @@ async def dashboard_polls(request: Request) -> HTMLResponse: try: msgs = await sub.fetch(1, timeout=1.0) if msgs: - import json data = json.loads(msgs[0].data.decode()) last_poll = data.get("data", {}).get("time", "—") adapters.append({ @@ -531,3 +550,294 @@ async def change_password_submit( # Redirect to index return RedirectResponse(url="/", status_code=302) + + +# ============================================================================= +# Adapters routes +# ============================================================================= + + +@router.get("/adapters", response_class=HTMLResponse) +async def adapters_list( + request: Request, + csrf_protect: CsrfProtect = Depends(), +) -> HTMLResponse: + """List all adapters.""" + templates = _get_templates() + pool = get_pool() + operator = request.state.operator + + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT name, enabled, cadence_s, settings, paused_at, updated_at + FROM config.adapters + ORDER BY name + """ + ) + + adapters = [] + for row in rows: + # asyncpg auto-deserializes jsonb to dict + settings = row["settings"] if row["settings"] else {} + if isinstance(settings, str): + settings = json.loads(settings) + adapters.append({ + "name": row["name"], + "enabled": row["enabled"], + "cadence_s": row["cadence_s"], + "settings": settings, + "paused_at": row["paused_at"], + "updated_at": row["updated_at"], + }) + + csrf_token, signed_token = csrf_protect.generate_csrf_tokens() + response = templates.TemplateResponse( + request=request, + name="adapters_list.html", + context={ + "operator": operator, + "csrf_token": csrf_token, + "adapters": adapters, + }, + ) + csrf_protect.set_csrf_cookie(signed_token, response) + return response + + +@router.get("/adapters/{name}", response_class=HTMLResponse) +async def adapters_edit_form( + request: Request, + name: str, + csrf_protect: CsrfProtect = Depends(), +) -> Response: + """Render the adapter edit form.""" + templates = _get_templates() + pool = get_pool() + operator = request.state.operator + + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT name, enabled, cadence_s, settings, paused_at, updated_at + FROM config.adapters + WHERE name = $1 + """, + name, + ) + + if row is None: + return Response(status_code=404, content="Adapter not found") + + # Get API keys for firms dropdown + api_keys = await conn.fetch( + "SELECT alias FROM config.api_keys ORDER BY alias" + ) + + # asyncpg auto-deserializes jsonb to dict + settings = row["settings"] if row["settings"] else {} + if isinstance(settings, str): + settings = json.loads(settings) + adapter = { + "name": row["name"], + "enabled": row["enabled"], + "cadence_s": row["cadence_s"], + "settings": settings, + "paused_at": row["paused_at"], + "updated_at": row["updated_at"], + } + + csrf_token, signed_token = csrf_protect.generate_csrf_tokens() + response = templates.TemplateResponse( + request=request, + name="adapters_edit.html", + context={ + "operator": operator, + "csrf_token": csrf_token, + "adapter": adapter, + "errors": None, + "form_data": None, + "api_keys": [{"alias": k["alias"]} for k in api_keys], + "valid_satellites": _get_valid_satellites(), + "valid_feeds": sorted(_get_valid_feeds()), + }, + ) + csrf_protect.set_csrf_cookie(signed_token, response) + return response + + +@router.post("/adapters/{name}") +async def adapters_edit_submit( + request: Request, + name: str, + csrf_protect: CsrfProtect = Depends(), +) -> Response: + """Process the adapter edit form.""" + templates = _get_templates() + pool = get_pool() + operator = request.state.operator + + # Validate CSRF + await csrf_protect.validate_csrf(request) + + # Parse form data + form = await request.form() + enabled = "enabled" in form + cadence_s_str = form.get("cadence_s", "") + + # Build form_data for re-render on error + form_data: dict[str, Any] = { + "enabled": enabled, + "cadence_s": cadence_s_str, + } + + errors: dict[str, str] = {} + + # Validate cadence_s + try: + cadence_s = int(cadence_s_str) + if cadence_s < 60 or cadence_s > 3600: + errors["cadence_s"] = "Cadence must be between 60 and 3600 seconds" + except ValueError: + errors["cadence_s"] = "Cadence must be a valid integer" + cadence_s = 0 + + async with pool.acquire() as conn: + # Get current adapter state + row = await conn.fetchrow( + """ + SELECT name, enabled, cadence_s, settings, paused_at, updated_at + FROM config.adapters + WHERE name = $1 + """, + name, + ) + + if row is None: + return Response(status_code=404, content="Adapter not found") + + # asyncpg auto-deserializes jsonb to dict + current_settings = row["settings"] if row["settings"] else {} + if isinstance(current_settings, str): + current_settings = json.loads(current_settings) + new_settings = dict(current_settings) + + # Adapter-specific validation and settings update + if name == "nws": + contact_email = form.get("contact_email", "").strip() + form_data["contact_email"] = contact_email + if not contact_email: + errors["contact_email"] = "Contact email is required" + elif not EMAIL_REGEX.match(contact_email): + errors["contact_email"] = "Invalid email format" + else: + new_settings["contact_email"] = contact_email + + elif name == "firms": + api_key_alias = form.get("api_key_alias", "").strip() + satellites = form.getlist("satellites") + form_data["api_key_alias"] = api_key_alias + form_data["satellites"] = satellites + + # Validate api_key_alias if set + if api_key_alias: + key_exists = await conn.fetchrow( + "SELECT 1 FROM config.api_keys WHERE alias = $1", + api_key_alias, + ) + if not key_exists: + errors["api_key_alias"] = f"API key alias '{api_key_alias}' does not exist" + else: + new_settings["api_key_alias"] = api_key_alias + else: + new_settings["api_key_alias"] = None + + # Validate satellites + valid_sats = set(_get_valid_satellites()) + invalid_sats = [s for s in satellites if s not in valid_sats] + if invalid_sats: + errors["satellites"] = f"Invalid satellites: {', '.join(invalid_sats)}" + else: + new_settings["satellites"] = satellites + + elif name == "usgs_quake": + feed = form.get("feed", "").strip() + form_data["feed"] = feed + valid_feeds = _get_valid_feeds() + if feed not in valid_feeds: + errors["feed"] = f"Invalid feed. Must be one of: {', '.join(sorted(valid_feeds))}" + else: + new_settings["feed"] = feed + + # If there are errors, re-render the form + if errors: + adapter = { + "name": row["name"], + "enabled": row["enabled"], + "cadence_s": row["cadence_s"], + "settings": current_settings, + "paused_at": row["paused_at"], + "updated_at": row["updated_at"], + } + + api_keys = await conn.fetch( + "SELECT alias FROM config.api_keys ORDER BY alias" + ) + + csrf_token, signed_token = csrf_protect.generate_csrf_tokens() + response = templates.TemplateResponse( + request=request, + name="adapters_edit.html", + context={ + "operator": operator, + "csrf_token": csrf_token, + "adapter": adapter, + "errors": errors, + "form_data": form_data, + "api_keys": [{"alias": k["alias"]} for k in api_keys], + "valid_satellites": _get_valid_satellites(), + "valid_feeds": sorted(_get_valid_feeds()), + }, + status_code=200, + ) + csrf_protect.set_csrf_cookie(signed_token, response) + return response + + # Build before state for audit + before = { + "enabled": row["enabled"], + "cadence_s": row["cadence_s"], + "settings": current_settings, + } + + # Build after state for audit + after = { + "enabled": enabled, + "cadence_s": cadence_s, + "settings": new_settings, + } + + # Update the adapter + await conn.execute( + """ + UPDATE config.adapters + SET enabled = $1, cadence_s = $2, settings = $3, updated_at = now() + WHERE name = $4 + """, + enabled, + cadence_s, + json.dumps(new_settings), + name, + ) + + # Write audit log + await write_audit( + conn, + ADAPTER_UPDATE, + operator_id=operator.id, + target=name, + before=before, + after=after, + ) + + return RedirectResponse(url="/adapters", status_code=302) diff --git a/src/central/gui/templates/adapters_edit.html b/src/central/gui/templates/adapters_edit.html new file mode 100644 index 0000000..fe7e093 --- /dev/null +++ b/src/central/gui/templates/adapters_edit.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}Central — Edit {{ adapter.name }}{% endblock %} + +{% block content %} +

Edit Adapter: {{ adapter.name }}

+ +
+ + +
+ Universal Settings + + + + + + {% if errors and errors.cadence_s %} + {{ errors.cadence_s }} + {% endif %} +
+ +
+ Adapter-Specific Settings + {% include "adapters_edit_" + adapter.name + ".html" %} +
+ +
+ Region (read-only) + {% if adapter.settings.region %} +

+ North: {{ adapter.settings.region.north }}
+ South: {{ adapter.settings.region.south }}
+ East: {{ adapter.settings.region.east }}
+ West: {{ adapter.settings.region.west }} +

+ {% else %} +

No region configured.

+ {% endif %} + Region editing comes in 1b-5. +
+ + + Cancel +
+{% endblock %} diff --git a/src/central/gui/templates/adapters_edit_firms.html b/src/central/gui/templates/adapters_edit_firms.html new file mode 100644 index 0000000..a2a339a --- /dev/null +++ b/src/central/gui/templates/adapters_edit_firms.html @@ -0,0 +1,21 @@ + + +{% if errors and errors.api_key_alias %} +{{ errors.api_key_alias }} +{% endif %} + + +{% for sat in valid_satellites %} + +{% endfor %} +{% if errors and errors.satellites %} +{{ errors.satellites }} +{% endif %} diff --git a/src/central/gui/templates/adapters_edit_nws.html b/src/central/gui/templates/adapters_edit_nws.html new file mode 100644 index 0000000..e655a41 --- /dev/null +++ b/src/central/gui/templates/adapters_edit_nws.html @@ -0,0 +1,5 @@ + + +{% if errors and errors.contact_email %} +{{ errors.contact_email }} +{% endif %} diff --git a/src/central/gui/templates/adapters_edit_usgs_quake.html b/src/central/gui/templates/adapters_edit_usgs_quake.html new file mode 100644 index 0000000..0c3b7ee --- /dev/null +++ b/src/central/gui/templates/adapters_edit_usgs_quake.html @@ -0,0 +1,9 @@ + + +{% if errors and errors.feed %} +{{ errors.feed }} +{% endif %} diff --git a/src/central/gui/templates/adapters_list.html b/src/central/gui/templates/adapters_list.html new file mode 100644 index 0000000..b97ae88 --- /dev/null +++ b/src/central/gui/templates/adapters_list.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %}Central — Adapters{% endblock %} + +{% block content %} +

Adapters

+ + + + + + + + + + + + {% for adapter in adapters %} + + + + + + + + {% endfor %} + +
NameEnabledCadenceLast Updated
{{ adapter.name }}{% if adapter.enabled %}Yes{% else %}No{% endif %}{{ adapter.cadence_s }}s{{ adapter.updated_at.strftime('%Y-%m-%d %H:%M') if adapter.updated_at else '—' }}Edit
+{% endblock %} diff --git a/src/central/gui/templates/base.html b/src/central/gui/templates/base.html index 631c542..1d2e24b 100644 --- a/src/central/gui/templates/base.html +++ b/src/central/gui/templates/base.html @@ -15,6 +15,8 @@