refactor(api): Pydantic request models for setup & admin routes (R6 S1b)

- setup: SetupAdminIn (KILLS the 3rd named 500 — a non-string password reaching
  argon2/len()), SetupTestEmailIn; setup_config stays a typed dict[str, Any] (a
  genuinely dynamic key→value settings map, not a fixed schema).
- admin: AddInviteIn / SetRoleIn / SetSuspendedIn / AddDemoWhitelistIn. The
  role/status business-rule 400s are unchanged; type errors are now 422.

All 3 ROADMAP-named 500-producers now fixed (channels priority + tag_id, setup
password). Gate: ruff clean, pytest 164 green.
This commit is contained in:
2026-07-26 04:56:55 +02:00
parent 99d65cea23
commit ab83aae8bb
2 changed files with 51 additions and 17 deletions
+27 -9
View File
@@ -3,6 +3,7 @@ Also the demo-account admin surface: the demo email whitelist + a manual state r
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -110,14 +111,18 @@ def deny_invite(
return _serialize(_decide(db, invite_id, admin, approved=False)) return _serialize(_decide(db, invite_id, admin, approved=False))
class AddInviteIn(BaseModel):
email: str = ""
@router.post("/invites") @router.post("/invites")
def add_invite( def add_invite(
payload: dict, body: AddInviteIn,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Manually whitelist an email (approved straight away). Convenience for the admin.""" """Manually whitelist an email (approved straight away). Convenience for the admin."""
email = (payload.get("email") or "").strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
@@ -157,17 +162,21 @@ def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
return [_serialize_user(u) for u in rows] return [_serialize_user(u) for u in rows]
class SetRoleIn(BaseModel):
role: str
@router.patch("/users/{user_id}/role") @router.patch("/users/{user_id}/role")
def set_user_role( def set_user_role(
user_id: int, user_id: int,
payload: dict, body: SetRoleIn,
background: BackgroundTasks, background: BackgroundTasks,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo """Promote/demote a user. Guards: can't change your own role, can't touch the demo
account, and can't remove the last remaining admin. Emails the user when it actually changes.""" account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = payload.get("role") role = body.role
if role not in ("user", "admin"): if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
target = db.get(User, user_id) target = db.get(User, user_id)
@@ -198,10 +207,14 @@ def set_user_role(
return _serialize_user(target) return _serialize_user(target)
class SetSuspendedIn(BaseModel):
suspended: bool = False
@router.patch("/users/{user_id}/suspend") @router.patch("/users/{user_id}/suspend")
def set_user_suspended( def set_user_suspended(
user_id: int, user_id: int,
payload: dict, body: SetSuspendedIn,
background: BackgroundTasks, background: BackgroundTasks,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -211,7 +224,7 @@ def set_user_suspended(
suspend yourself, and can't suspend the last active admin (that would lock admin out). On suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in).""" sign-in)."""
suspended = bool(payload.get("suspended")) suspended = body.suspended
target = db.get(User, user_id) target = db.get(User, user_id)
if target is None: if target is None:
raise HTTPException(status_code=404, detail="No such user") raise HTTPException(status_code=404, detail="No such user")
@@ -296,15 +309,20 @@ def list_demo_whitelist(
return [_serialize_demo(r) for r in rows] return [_serialize_demo(r) for r in rows]
class AddDemoWhitelistIn(BaseModel):
email: str = ""
note: str | None = None
@router.post("/demo/whitelist") @router.post("/demo/whitelist")
def add_demo_whitelist( def add_demo_whitelist(
payload: dict, body: AddDemoWhitelistIn,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Add an email that may enter the shared demo account from the login page (no Google """Add an email that may enter the shared demo account from the login page (no Google
sign-in). Idempotent — re-adding an existing email just returns it.""" sign-in). Idempotent — re-adding an existing email just returns it."""
email = (payload.get("email") or "").strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
row = db.execute( row = db.execute(
@@ -313,7 +331,7 @@ def add_demo_whitelist(
if row is None: if row is None:
row = DemoWhitelist( row = DemoWhitelist(
email=email, email=email,
note=(payload.get("note") or "").strip() or None, note=(body.note or "").strip() or None,
added_by=admin.email, added_by=admin.email,
) )
db.add(row) db.add(row)
+24 -8
View File
@@ -5,7 +5,10 @@ mutating step goes through `require_setup`: it rejects once the instance is conf
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
X-Setup-Token header). The wizard steps themselves are added in epic 6c. X-Setup-Token header). The wizard steps themselves are added in epic 6c.
""" """
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -45,12 +48,20 @@ def setup_info(db: Session = Depends(require_setup)) -> dict:
return {"secrets_manageable": sysconfig.secrets_manageable()} return {"secrets_manageable": sysconfig.secrets_manageable()}
class SetupAdminIn(BaseModel):
# Both default to "" so a missing field stays the wizard's own 400 ("Enter a valid email" /
# password too short) rather than a 422. `password: str` also stops a non-string value from
# reaching argon2 (which would 500 in len()/hash_password) — it's now a clean 422.
email: str = ""
password: str = ""
@router.post("/admin") @router.post("/admin")
def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict: def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dict:
"""Create (or update) the first administrator. Active + verified immediately — the operator is """Create (or update) the first administrator. Active + verified immediately — the operator is
configuring the instance, so there's no email-verification / approval gate here.""" configuring the instance, so there's no email-verification / approval gate here."""
email = (payload.get("email") or "").strip().lower() email = body.email.strip().lower()
password = payload.get("password") or "" password = body.password
if "@" not in email: if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN: if len(password) < PASSWORD_MIN_LEN:
@@ -70,11 +81,12 @@ def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
@router.post("/config") @router.post("/config")
def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict: def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict:
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the """Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
sysconfig registry; empty values are skipped (left at the default).""" sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic
key→value map, so it stays a typed dict rather than a fixed model."""
saved: list[str] = [] saved: list[str] = []
for key, value in (payload or {}).items(): for key, value in settings_map.items():
if sysconfig.spec(key) is None: if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}") raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None or value == "": if value is None or value == "":
@@ -87,10 +99,14 @@ def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
return {"saved": saved} return {"saved": saved}
class SetupTestEmailIn(BaseModel):
to: str = ""
@router.post("/test-email") @router.post("/test-email")
def setup_test_email(payload: dict, db: Session = Depends(require_setup)) -> dict: def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup)) -> dict:
"""Send a test email to confirm the SMTP settings the wizard just saved.""" """Send a test email to confirm the SMTP settings the wizard just saved."""
to = (payload.get("to") or "").strip() to = body.to.strip()
if "@" not in to: if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to): if not email_mod.send_test(to):