refactor(api): Pydantic request models for small route modules (R6 S1b)

payload:dict → typed request models across the single/double-endpoint modules:
- tags: TagCreateIn / TagUpdateIn (color:null clears → reads model_fields_set);
  _own_tag now delegates to owned_or_404.
- config: ConfigSetIn(value:Any) — keeps the "Missing 'value'" 400 via model_fields_set.
- me: SwitchAccountIn(user_id:int).
- scheduler: JobIntervalIn / MaintenanceSettingsIn — the manual int()+400 becomes a
  clean 422 on a non-number; the range checks stay 400.
- search: ClearSearchIn(video_ids:list[str]).
- public: WatchUnlockIn(password:str).

Gate: ruff clean, pytest 164 green.
This commit is contained in:
2026-07-26 04:51:25 +02:00
parent d982e8b9ae
commit a622470a28
6 changed files with 75 additions and 34 deletions
+12 -3
View File
@@ -1,8 +1,10 @@
"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in
app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned."""
import re
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app import audit
@@ -30,17 +32,24 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
return sysconfig.describe(db)
class ConfigSetIn(BaseModel):
# A config value is arbitrarily typed (str/int/bool/…), validated downstream by
# sysconfig.set_value. `value` defaults to a sentinel so a MISSING value stays a 400
# ("Missing 'value'") while an explicit `null` is a real, settable value.
value: Any = None
@router.patch("/{key}")
def set_config(
key: str,
payload: dict,
body: ConfigSetIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
if s is None:
raise HTTPException(status_code=404, detail="Unknown config key")
if "value" not in payload:
if "value" not in body.model_fields_set:
raise HTTPException(status_code=400, detail="Missing 'value'")
if s.secret and not sysconfig.secrets_manageable():
raise HTTPException(
@@ -49,7 +58,7 @@ def set_config(
)
old = None if s.secret else sysconfig.get(db, key)
try:
sysconfig.set_value(db, key, payload["value"])
sysconfig.set_value(db, key, body.value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
+7 -2
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -45,9 +46,13 @@ def my_accounts(
return out
class SwitchAccountIn(BaseModel):
user_id: int
@router.post("/switch")
def switch_account(
payload: dict,
body: SwitchAccountIn,
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -55,7 +60,7 @@ def switch_account(
"""Switch the active account to another one already authenticated in this browser. No
Google round-trip — but only accounts present in the session list (proof they signed in
here) are allowed, and access is re-checked in case the invite was revoked since."""
target = payload.get("user_id")
target = body.user_id
if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
u = db.get(User, target)
+7 -2
View File
@@ -8,6 +8,7 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -79,8 +80,12 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
}
class WatchUnlockIn(BaseModel):
password: str = ""
@router.post("/watch/{token}/unlock")
def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> dict:
def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db)) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
if not _unlock_limit.allow(token):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
@@ -92,7 +97,7 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
"needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
if not verify_password((payload.get("password") or ""), link.password_hash):
if not verify_password(body.password, link.password_hash):
raise HTTPException(status_code=403, detail="Wrong password.")
job, asset = _ready_target(db, link)
link.view_count += 1
+13 -10
View File
@@ -2,6 +2,7 @@
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
@@ -25,10 +26,14 @@ from app.sync.runner import estimate_deep_backfill
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
class JobIntervalIn(BaseModel):
interval_minutes: int
@router.patch("/jobs/{job_id}")
def set_job_interval(
job_id: str,
payload: dict,
body: JobIntervalIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
@@ -36,10 +41,7 @@ def set_job_interval(
to the live scheduler immediately."""
if job_id not in JOB_INTERVALS:
raise HTTPException(status_code=404, detail="Unknown job")
try:
minutes = int(payload.get("interval_minutes"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="interval_minutes must be a number")
minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise HTTPException(
status_code=400,
@@ -77,18 +79,19 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
return {"started": trigger_all(actor_id=user.id)}
class MaintenanceSettingsIn(BaseModel):
revalidate_batch: int
@router.patch("/maintenance")
def set_maintenance_settings(
payload: dict,
body: MaintenanceSettingsIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
run). Persisted to app_state; the job reads it each run."""
try:
value = int(payload.get("revalidate_batch"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="revalidate_batch must be a number")
value = body.revalidate_batch
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
raise HTTPException(
status_code=400,
+7 -2
View File
@@ -16,6 +16,7 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased
@@ -323,9 +324,13 @@ def search_youtube(
}
class ClearSearchIn(BaseModel):
video_ids: list[str] = []
@router.post("/clear")
def clear_search(
payload: dict,
body: ClearSearchIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
@@ -333,7 +338,7 @@ def clear_search(
for them, then hard-delete the now-orphaned ones — a via_search video nobody KEPT (no watch
state, not playlisted, channel not subscribed) and that no other user still has as a search
find. Kept videos and organically-owned ones are left untouched."""
ids = [str(v) for v in (payload.get("video_ids") or [])]
ids = body.video_ids
if not ids:
return {"removed": 0}
db.execute(
+29 -15
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -6,6 +7,7 @@ from sqlalchemy.orm import Session
from app.auth import admin_user, current_user
from app.db import get_db
from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404
from app.sync.autotag import run_autotag_all
router = APIRouter(prefix="/api/tags", tags=["tags"])
@@ -33,21 +35,28 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
]
class TagCreateIn(BaseModel):
name: str
category: str = "other"
color: str | None = None
@router.post("")
def create_tag(
payload: dict,
body: TagCreateIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
name = (payload.get("name") or "").strip()
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
category = payload.get("category") or "other"
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
category = body.category if body.category in ("language", "topic", "other") else "other"
tag = Tag(
user_id=user.id,
name=name,
color=payload.get("color"),
category=category if category in ("language", "topic", "other") else "other",
color=body.color,
category=category,
)
db.add(tag)
try:
@@ -59,28 +68,33 @@ def create_tag(
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
tag = db.get(Tag, tag_id)
if tag is None or tag.user_id != user.id:
# System tags (user_id NULL) are not user-editable.
raise HTTPException(status_code=404, detail="Unknown tag")
return tag
# System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
return owned_or_404(db, Tag, tag_id, user, detail="Unknown tag")
class TagUpdateIn(BaseModel):
# PATCH: presence matters, and `color: null` explicitly CLEARS it — so the endpoint reads
# model_fields_set rather than testing for None (which couldn't tell absent from cleared).
name: str | None = None
color: str | None = None
@router.patch("/{tag_id}")
def update_tag(
tag_id: int,
payload: dict,
body: TagUpdateIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
tag = _own_tag(db, user, tag_id)
if "name" in payload:
name = (payload.get("name") or "").strip()
sent = body.model_fields_set
if "name" in sent:
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
tag.name = name
if "color" in payload:
tag.color = payload.get("color")
if "color" in sent:
tag.color = body.color
try:
db.commit()
except IntegrityError: