diff --git a/src/central/gui/form_descriptors.py b/src/central/gui/form_descriptors.py new file mode 100644 index 0000000..0d17d1f --- /dev/null +++ b/src/central/gui/form_descriptors.py @@ -0,0 +1,133 @@ +"""Form field descriptors for adapter settings. + +If a second nested settings type beyond RegionConfig appears, +refactor this helper to recurse over nested models. +""" + +from dataclasses import dataclass +from typing import Any, Union, get_args, get_origin + +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + +from central.config_models import RegionConfig + + +@dataclass +class FieldDescriptor: + """Describes a form field for rendering.""" + name: str + label: str + widget: str # "text", "number", "checkbox", "csv", "region" + current_value: Any + default: Any + description: str + required: bool + + +def _type_to_widget(field_name: str, field_type: type) -> str: + """Map a Python type to a widget type.""" + # Handle Optional/Union types + origin = get_origin(field_type) + args = get_args(field_type) + + # Check for Optional[X] (Union[X, None]) + if origin is Union or (origin is not None and type(None) in args): + # Get the non-None type + non_none_args = [a for a in args if a is not type(None)] + if non_none_args: + inner_type = non_none_args[0] + # Recursively determine widget for the inner type + return _type_to_widget(field_name, inner_type) + + # Direct type checks + if field_type is str: + return "text" + if field_type is int: + return "number" + if field_type is bool: + return "checkbox" + if field_type is RegionConfig: + return "region" + + # Check for list[str] + if origin is list: + if args and args[0] is str: + return "csv" + raise NotImplementedError( + f"Field '{field_name}' has unsupported list type: list[{args[0].__name__ if args else '?'}]" + ) + + # Check if it's a BaseModel subclass (nested model) + if isinstance(field_type, type) and issubclass(field_type, BaseModel): + if field_type is RegionConfig: + return "region" + raise NotImplementedError( + f"Field '{field_name}' has unsupported nested type: {field_type.__name__}" + ) + + raise NotImplementedError( + f"Field '{field_name}' has unsupported type: {field_type}" + ) + + +def _name_to_label(name: str) -> str: + """Convert field name to human-readable label.""" + return name.replace("_", " ").title() + + +def _is_undefined(value: Any) -> bool: + """Check if a value is Pydantic's undefined sentinel.""" + return value is PydanticUndefined + + +def describe_fields(model_cls: type[BaseModel], current: dict) -> list[FieldDescriptor]: + """Generate field descriptors for a Pydantic model. + + Args: + model_cls: The Pydantic model class (e.g., NWSSettings) + current: Current settings values from the database + + Returns: + List of FieldDescriptor objects for rendering the form + """ + descriptors = [] + + for field_name, field_info in model_cls.model_fields.items(): + # Get the field type + field_type = field_info.annotation + + # Determine widget + widget = _type_to_widget(field_name, field_type) + + # Get current value, falling back to default + if field_name in current: + current_value = current[field_name] + elif not _is_undefined(field_info.default): + current_value = field_info.default + else: + current_value = None + + # Get default + default = field_info.default if not _is_undefined(field_info.default) else None + + # Get description + description = "" + if field_info.description: + description = field_info.description + + # Determine if required (no default and not Optional) + required = _is_undefined(field_info.default) and field_info.is_required() + + descriptors.append(FieldDescriptor( + name=field_name, + label=_name_to_label(field_name), + widget=widget, + current_value=current_value, + default=default, + description=description, + required=required, + )) + + return descriptors diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 3a415c2..cb46fe8 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -9,6 +9,7 @@ from typing import Any logger = logging.getLogger("central.gui.routes") + from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from central.bootstrap_config import get_settings @@ -43,7 +44,22 @@ from central.gui.audit import ( SYSTEM_UPDATE, write_audit, ) +from functools import cache + from central.gui.db import get_pool +from central.gui.form_descriptors import describe_fields, FieldDescriptor +from central.supervisor import discover_adapters +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() + router = APIRouter() @@ -1275,10 +1291,14 @@ async def adapters_edit_form( pool = get_pool() operator = request.state.operator + # Look up the adapter class + adapter_classes = _adapter_classes() + adapter_cls = adapter_classes.get(name) + async with pool.acquire() as conn: row = await conn.fetchrow( """ - SELECT name, enabled, cadence_s, settings, paused_at, updated_at + SELECT name, enabled, cadence_s, settings, paused_at, updated_at, last_error FROM config.adapters WHERE name = $1 """, @@ -1288,11 +1308,6 @@ async def adapters_edit_form( 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" - ) - # Get map tile settings from config.system sys_row = await conn.fetchrow( "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true" @@ -1301,15 +1316,25 @@ async def adapters_edit_form( tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors" settings = row["settings"] or {} + + # Build adapter dict with class metadata adapter = { "name": row["name"], + "display_name": getattr(adapter_cls, "display_name", row["name"]) if adapter_cls else row["name"], + "description": getattr(adapter_cls, "description", "") if adapter_cls else "", "enabled": row["enabled"], "cadence_s": row["cadence_s"], "settings": settings, "paused_at": row["paused_at"], "updated_at": row["updated_at"], + "last_error": row["last_error"], } + # Generate field descriptors if we have the adapter class + fields = [] + if adapter_cls and hasattr(adapter_cls, "settings_schema"): + fields = describe_fields(adapter_cls.settings_schema, settings) + csrf_token = request.state.csrf_token response = templates.TemplateResponse( request=request, @@ -1318,11 +1343,9 @@ async def adapters_edit_form( "operator": operator, "csrf_token": csrf_token, "adapter": adapter, + "fields": fields, "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()), "tile_url": tile_url, "tile_attribution": tile_attribution, }, @@ -1347,19 +1370,20 @@ async def adapters_edit_submit( if not form_csrf or form_csrf != request.state.csrf_token: raise CsrfValidationError("Invalid CSRF token") - # Parse form data - form = await request.form() + # Look up the adapter class + adapter_classes = _adapter_classes() + adapter_cls = adapter_classes.get(name) + + # Parse common form fields enabled = "enabled" in form cadence_s_str = form.get("cadence_s", "") - # Build form_data for re-render on error + errors: dict[str, str] = {} 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) @@ -1373,7 +1397,7 @@ async def adapters_edit_submit( # Get current adapter state row = await conn.fetchrow( """ - SELECT name, enabled, cadence_s, settings, paused_at, updated_at + SELECT name, enabled, cadence_s, settings, paused_at, updated_at, last_error FROM config.adapters WHERE name = $1 """, @@ -1384,103 +1408,91 @@ async def adapters_edit_submit( return Response(status_code=404, content="Adapter not found") current_settings = row["settings"] or {} - 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" + # Parse and validate settings via Pydantic if we have the adapter class + new_settings = {} + if adapter_cls and hasattr(adapter_cls, "settings_schema"): + schema = adapter_cls.settings_schema + fields = describe_fields(schema, current_settings) + + # Parse form values based on widget type + parsed_values = {} + for field in fields: + raw = form.get(field.name, "") + form_data[field.name] = raw + + if field.widget == "text": + parsed_values[field.name] = raw.strip() if raw else None + elif field.widget == "number": + try: + parsed_values[field.name] = int(raw) if raw else None + except ValueError: + errors[field.name] = f"{field.label} must be a number" + elif field.widget == "checkbox": + parsed_values[field.name] = field.name in form + elif field.widget == "csv": + if raw.strip(): + parsed_values[field.name] = [v.strip() for v in raw.split(",") if v.strip()] + else: + parsed_values[field.name] = [] + elif field.widget == "region": + # Region handled separately below + pass + + # Handle region fields (common pattern) + region_north_str = form.get("region_north", "").strip() + region_south_str = form.get("region_south", "").strip() + region_east_str = form.get("region_east", "").strip() + region_west_str = form.get("region_west", "").strip() + + form_data["region_north"] = region_north_str + form_data["region_south"] = region_south_str + form_data["region_east"] = region_east_str + form_data["region_west"] = region_west_str + + # Check if any region field has a value + has_region = any([region_north_str, region_south_str, region_east_str, region_west_str]) + + if has_region: + try: + region_north = float(region_north_str) + region_south = float(region_south_str) + region_east = float(region_east_str) + region_west = float(region_west_str) + + if not (-90 <= region_south < region_north <= 90): + errors["region"] = "Invalid latitude: south must be less than north, both between -90 and 90" + elif not (-180 <= region_west < region_east <= 180): + errors["region"] = "Invalid longitude: west must be less than east, both between -180 and 180" + else: + parsed_values["region"] = { + "north": region_north, + "south": region_south, + "east": region_east, + "west": region_west, + } + except ValueError: + errors["region"] = "Region coordinates must be valid numbers" else: - new_settings["contact_email"] = contact_email + parsed_values["region"] = None - 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 - - # Region validation (applies to all adapters) - region_north_str = form.get("region_north", "").strip() - region_south_str = form.get("region_south", "").strip() - region_east_str = form.get("region_east", "").strip() - region_west_str = form.get("region_west", "").strip() - - form_data["region_north"] = region_north_str - form_data["region_south"] = region_south_str - form_data["region_east"] = region_east_str - form_data["region_west"] = region_west_str - - try: - region_north = float(region_north_str) - region_south = float(region_south_str) - region_east = float(region_east_str) - region_west = float(region_west_str) - - # Validate latitude bounds - if not (-90 <= region_south < region_north <= 90): - errors["region"] = "Invalid latitude: south must be less than north, both between -90 and 90" - # Validate longitude bounds - elif not (-180 <= region_west < region_east <= 180): - errors["region"] = "Invalid longitude: west must be less than east, both between -180 and 180" - else: - new_settings["region"] = { - "north": region_north, - "south": region_south, - "east": region_east, - "west": region_west, - } - except ValueError: - errors["region"] = "Region coordinates must be valid numbers" + # Only validate with Pydantic if no parse errors + if not errors: + try: + # Filter out None values for optional fields without defaults + validated_data = {k: v for k, v in parsed_values.items() if v is not None} + validated = schema(**validated_data) + new_settings = validated.model_dump(mode="json") + except ValidationError as e: + for err in e.errors(): + field_name = err["loc"][0] if err["loc"] else "unknown" + errors[str(field_name)] = err["msg"] + else: + # No schema - just preserve existing settings + new_settings = dict(current_settings) # 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" - ) - # Get map tile settings for re-render sys_row = await conn.fetchrow( "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true" @@ -1488,6 +1500,22 @@ async def adapters_edit_submit( tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors" + adapter = { + "name": row["name"], + "display_name": getattr(adapter_cls, "display_name", row["name"]) if adapter_cls else row["name"], + "description": getattr(adapter_cls, "description", "") if adapter_cls else "", + "enabled": row["enabled"], + "cadence_s": row["cadence_s"], + "settings": current_settings, + "paused_at": row["paused_at"], + "updated_at": row["updated_at"], + "last_error": row["last_error"], + } + + fields = [] + if adapter_cls and hasattr(adapter_cls, "settings_schema"): + fields = describe_fields(adapter_cls.settings_schema, current_settings) + csrf_token = request.state.csrf_token response = templates.TemplateResponse( request=request, @@ -1496,11 +1524,9 @@ async def adapters_edit_submit( "operator": operator, "csrf_token": csrf_token, "adapter": adapter, + "fields": fields, "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()), "tile_url": tile_url, "tile_attribution": tile_attribution, }, diff --git a/src/central/gui/templates/adapters_edit.html b/src/central/gui/templates/adapters_edit.html index 939aa75..563174d 100644 --- a/src/central/gui/templates/adapters_edit.html +++ b/src/central/gui/templates/adapters_edit.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Central — Edit {{ adapter.name }}{% endblock %} +{% block title %}Central — Edit {{ adapter.display_name }}{% endblock %} {% block head %} @@ -10,35 +10,114 @@ {% endblock %} {% block content %} -
{{ adapter.description }}
+ +{% if adapter.paused_at %} +