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:
@@ -3,6 +3,7 @@ Also the demo-account admin surface: the demo email whitelist + a manual state r
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -110,14 +111,18 @@ def deny_invite(
|
||||
return _serialize(_decide(db, invite_id, admin, approved=False))
|
||||
|
||||
|
||||
class AddInviteIn(BaseModel):
|
||||
email: str = ""
|
||||
|
||||
|
||||
@router.post("/invites")
|
||||
def add_invite(
|
||||
payload: dict,
|
||||
body: AddInviteIn,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
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]
|
||||
|
||||
|
||||
class SetRoleIn(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/role")
|
||||
def set_user_role(
|
||||
user_id: int,
|
||||
payload: dict,
|
||||
body: SetRoleIn,
|
||||
background: BackgroundTasks,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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."""
|
||||
role = payload.get("role")
|
||||
role = body.role
|
||||
if role not in ("user", "admin"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
||||
target = db.get(User, user_id)
|
||||
@@ -198,10 +207,14 @@ def set_user_role(
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
class SetSuspendedIn(BaseModel):
|
||||
suspended: bool = False
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/suspend")
|
||||
def set_user_suspended(
|
||||
user_id: int,
|
||||
payload: dict,
|
||||
body: SetSuspendedIn,
|
||||
background: BackgroundTasks,
|
||||
admin: User = Depends(admin_user),
|
||||
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
|
||||
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||
sign-in)."""
|
||||
suspended = bool(payload.get("suspended"))
|
||||
suspended = body.suspended
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
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]
|
||||
|
||||
|
||||
class AddDemoWhitelistIn(BaseModel):
|
||||
email: str = ""
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@router.post("/demo/whitelist")
|
||||
def add_demo_whitelist(
|
||||
payload: dict,
|
||||
body: AddDemoWhitelistIn,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""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."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = body.email.strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
row = db.execute(
|
||||
@@ -313,7 +331,7 @@ def add_demo_whitelist(
|
||||
if row is None:
|
||||
row = DemoWhitelist(
|
||||
email=email,
|
||||
note=(payload.get("note") or "").strip() or None,
|
||||
note=(body.note or "").strip() or None,
|
||||
added_by=admin.email,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
@@ -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
|
||||
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 pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -45,12 +48,20 @@ def setup_info(db: Session = Depends(require_setup)) -> dict:
|
||||
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")
|
||||
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
|
||||
configuring the instance, so there's no email-verification / approval gate here."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
email = body.email.strip().lower()
|
||||
password = body.password
|
||||
if "@" not in email:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
@@ -70,11 +81,12 @@ def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
|
||||
|
||||
|
||||
@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
|
||||
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] = []
|
||||
for key, value in (payload or {}).items():
|
||||
for key, value in settings_map.items():
|
||||
if sysconfig.spec(key) is None:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
|
||||
if value is None or value == "":
|
||||
@@ -87,10 +99,14 @@ def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
|
||||
return {"saved": saved}
|
||||
|
||||
|
||||
class SetupTestEmailIn(BaseModel):
|
||||
to: str = ""
|
||||
|
||||
|
||||
@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."""
|
||||
to = (payload.get("to") or "").strip()
|
||||
to = body.to.strip()
|
||||
if "@" not in to:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not email_mod.send_test(to):
|
||||
|
||||
Reference in New Issue
Block a user