fix(gui): generic model_list editor for list-of-model adapters + TomTom bbox validation & quota (v0.9.9)

Fixes a shared form_descriptors 500 (NotImplementedError: unsupported list
type) that broke the Edit page for ALL FOUR adapters whose settings carry a
list[<BaseModel>] field: tomtom_incidents, tomtom_flow, state_511_atis,
state_511_atis_cameras.

- form_descriptors: list[BaseModel] -> generic "model_list" widget with
  recursive per-column sub_field descriptors.
- New _partials/model_list.html: vanilla-JS repeatable-row editor
  (add/remove/renumber), driven entirely by sub_fields (no adapter-name
  branching). Single-region edit pages render byte-identically.
- TomTom: BBox/Settings Pydantic validators (10,000 km^2 cap, coord ranges,
  min<max, cadence_s>=60, unique names) as the single source of truth
  (enforced at supervisor load AND GUI POST). Duck-typed quota_estimate hook
  + read-only quota panel; POST hard-blocks estimates over the 2,500/mo free
  tier (422). TOMTOM_FREE_TIER_CALLS_PER_MONTH is a tunable for paid tiers.
- routes: model_list form parse, row-aware ValidationError messages, 422 for
  model_list failures (single-region region errors still re-render at 200).
- tests: 11 new (real-Jinja render across 3 adapters + byte-identical nws
  no-regression guard, POST persist + oversized/degenerate/duplicate/cadence/
  quota 422 matrix, quota estimate). Full suite 848 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-26 05:57:34 +00:00
commit 7a5092c77f
8 changed files with 483 additions and 6 deletions

View file

@ -1392,6 +1392,39 @@ async def adapters_list(
return response
def _parse_model_list(form, field) -> list[dict]:
"""Reconstruct a list[dict] from indexed form keys ``<field>-<i>-<sub>``.
Coerces numeric sub-fields; omits blank optional numbers (model default
applies); drops fully-empty rows (e.g. a blank cloned template row)."""
sub_widgets = {sf.name: sf.widget for sf in (field.sub_fields or [])}
rows: dict[int, dict] = {}
prefix = f"{field.name}-"
for key, value in form.multi_items():
if not key.startswith(prefix):
continue
idx_str, _, sub = key[len(prefix):].partition("-")
if not sub or not idx_str.isdigit():
continue
rows.setdefault(int(idx_str), {})[sub] = value
out: list[dict] = []
for idx in sorted(rows):
row: dict = {}
for sub, raw in rows[idx].items():
val = (raw or "").strip()
if sub_widgets.get(sub) == "number":
if val == "":
continue # -> use model default (e.g. cadence_s=None)
try:
row[sub] = float(val) if "." in val or "e" in val.lower() else int(val)
except ValueError:
row[sub] = val # let Pydantic raise a typed error
else:
row[sub] = val
if any(v != "" for v in row.values()):
out.append(row)
return out
@router.get("/adapters/{name}", response_class=HTMLResponse)
async def adapters_edit_form(
request: Request,
@ -1484,6 +1517,16 @@ async def adapters_edit_form(
except Exception as exc:
preview_error = f"Preview unavailable: {exc}"
# Read-only API-quota panel (adapters opt in via quota_estimate; duck-typed).
quota: dict | None = None
if adapter_cls is not None and hasattr(adapter_cls, "settings_schema"):
try:
quota = adapter_cls.quota_estimate(
adapter_cls.settings_schema(**settings), row["cadence_s"]
)
except Exception:
quota = None
csrf_token = request.state.csrf_token
response = templates.TemplateResponse(
request=request,
@ -1502,6 +1545,7 @@ async def adapters_edit_form(
"requires_api_key_alias": requires_api_key_alias,
"preview_rows": preview_rows,
"preview_error": preview_error,
"quota": quota,
},
)
return response
@ -1613,6 +1657,10 @@ async def adapters_edit_submit(
# API key select - validate against existing keys
value = raw.strip() if raw else None
parsed_values[field.name] = value
elif field.widget == "model_list":
rows = _parse_model_list(form, field)
form_data[field.name] = rows
parsed_values[field.name] = rows
elif field.widget == "region":
# Region handled separately below
pass
@ -1661,10 +1709,24 @@ async def adapters_edit_submit(
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")
# Hard-block a save that would blow the provider free tier.
q = adapter_cls.quota_estimate(validated, cadence_s)
if q and q.get("blocked"):
ml = next((f.name for f in fields if f.widget == "model_list"), "quota")
errors[ml] = (
f"Estimated {q['calls_per_month']:,} calls/month exceeds the "
f"{q['cap']:,}/month free-tier cap — raise cadence or remove rows."
)
except ValidationError as e:
ml_name = next((f.name for f in fields if f.widget == "model_list"), None)
for err in e.errors():
field_name = err["loc"][0] if err["loc"] else "unknown"
errors[str(field_name)] = err["msg"]
loc = err["loc"]
key = str(loc[0]) if loc else (ml_name or "unknown")
if len(loc) >= 2 and isinstance(loc[1], int):
errors[key] = f"Row {loc[1] + 1}: {err['msg']}"
else:
errors[key] = err["msg"]
else:
# No schema - just preserve existing settings
new_settings = dict(current_settings)
@ -1709,6 +1771,18 @@ async def adapters_edit_submit(
)
api_key_missing = not has_key
quota = None
if adapter_cls and hasattr(adapter_cls, "settings_schema"):
try:
quota = adapter_cls.quota_estimate(
adapter_cls.settings_schema(**(new_settings or current_settings)),
cadence_s,
)
except Exception:
quota = None
# list-of-model validation failures are a 422; single-region stays 200.
status = 422 if any(f.widget == "model_list" for f in fields) else 200
csrf_token = request.state.csrf_token
response = templates.TemplateResponse(
request=request,
@ -1725,8 +1799,9 @@ async def adapters_edit_submit(
"tile_attribution": tile_attribution,
"api_key_missing": api_key_missing,
"requires_api_key_alias": requires_api_key_alias,
"quota": quota,
},
status_code=200,
status_code=status,
)
return response