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 """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.""" app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned."""
import re import re
from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import audit from app import audit
@@ -30,17 +32,24 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
return sysconfig.describe(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}") @router.patch("/{key}")
def set_config( def set_config(
key: str, key: str,
payload: dict, body: ConfigSetIn,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
s = sysconfig.spec(key) s = sysconfig.spec(key)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Unknown config key") 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'") raise HTTPException(status_code=400, detail="Missing 'value'")
if s.secret and not sysconfig.secrets_manageable(): if s.secret and not sysconfig.secrets_manageable():
raise HTTPException( raise HTTPException(
@@ -49,7 +58,7 @@ def set_config(
) )
old = None if s.secret else sysconfig.get(db, key) old = None if s.secret else sysconfig.get(db, key)
try: try:
sysconfig.set_value(db, key, payload["value"]) sysconfig.set_value(db, key, body.value)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") 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 # 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 fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -45,9 +46,13 @@ def my_accounts(
return out return out
class SwitchAccountIn(BaseModel):
user_id: int
@router.post("/switch") @router.post("/switch")
def switch_account( def switch_account(
payload: dict, body: SwitchAccountIn,
request: Request, request: Request,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), 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 """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 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.""" 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 []): if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.") raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
u = db.get(User, target) 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 import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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") @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.""" """Exchange the link password for a signed grant carried by the media URL."""
if not _unlock_limit.allow(token): if not _unlock_limit.allow(token):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.") 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, "needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)), **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.") raise HTTPException(status_code=403, detail="Wrong password.")
job, asset = _ready_target(db, link) job, asset = _ready_target(db, link)
link.view_count += 1 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), per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture.""" and the shared quota picture."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session 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"]) router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
class JobIntervalIn(BaseModel):
interval_minutes: int
@router.patch("/jobs/{job_id}") @router.patch("/jobs/{job_id}")
def set_job_interval( def set_job_interval(
job_id: str, job_id: str,
payload: dict, body: JobIntervalIn,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -36,10 +41,7 @@ def set_job_interval(
to the live scheduler immediately.""" to the live scheduler immediately."""
if job_id not in JOB_INTERVALS: if job_id not in JOB_INTERVALS:
raise HTTPException(status_code=404, detail="Unknown job") raise HTTPException(status_code=404, detail="Unknown job")
try: minutes = body.interval_minutes
minutes = int(payload.get("interval_minutes"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="interval_minutes must be a number")
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL): if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise HTTPException( raise HTTPException(
status_code=400, 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)} return {"started": trigger_all(actor_id=user.id)}
class MaintenanceSettingsIn(BaseModel):
revalidate_batch: int
@router.patch("/maintenance") @router.patch("/maintenance")
def set_maintenance_settings( def set_maintenance_settings(
payload: dict, body: MaintenanceSettingsIn,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per """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.""" run). Persisted to app_state; the job reads it each run."""
try: value = body.revalidate_batch
value = int(payload.get("revalidate_batch"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="revalidate_batch must be a number")
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX): if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
+7 -2
View File
@@ -16,6 +16,7 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@@ -323,9 +324,13 @@ def search_youtube(
} }
class ClearSearchIn(BaseModel):
video_ids: list[str] = []
@router.post("/clear") @router.post("/clear")
def clear_search( def clear_search(
payload: dict, body: ClearSearchIn,
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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 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 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.""" 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: if not ids:
return {"removed": 0} return {"removed": 0}
db.execute( db.execute(
+29 -15
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,6 +7,7 @@ from sqlalchemy.orm import Session
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import ChannelTag, Tag, User from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404
from app.sync.autotag import run_autotag_all from app.sync.autotag import run_autotag_all
router = APIRouter(prefix="/api/tags", tags=["tags"]) 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("") @router.post("")
def create_tag( def create_tag(
payload: dict, body: TagCreateIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
name = (payload.get("name") or "").strip() name = body.name.strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name is required") 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( tag = Tag(
user_id=user.id, user_id=user.id,
name=name, name=name,
color=payload.get("color"), color=body.color,
category=category if category in ("language", "topic", "other") else "other", category=category,
) )
db.add(tag) db.add(tag)
try: try:
@@ -59,28 +68,33 @@ def create_tag(
def _own_tag(db: Session, user: User, tag_id: int) -> Tag: def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
tag = db.get(Tag, tag_id) # System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
if tag is None or tag.user_id != user.id: return owned_or_404(db, Tag, tag_id, user, detail="Unknown tag")
# System tags (user_id NULL) are not user-editable.
raise HTTPException(status_code=404, detail="Unknown tag")
return 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}") @router.patch("/{tag_id}")
def update_tag( def update_tag(
tag_id: int, tag_id: int,
payload: dict, body: TagUpdateIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
tag = _own_tag(db, user, tag_id) tag = _own_tag(db, user, tag_id)
if "name" in payload: sent = body.model_fields_set
name = (payload.get("name") or "").strip() if "name" in sent:
name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name cannot be empty") raise HTTPException(status_code=400, detail="name cannot be empty")
tag.name = name tag.name = name
if "color" in payload: if "color" in sent:
tag.color = payload.get("color") tag.color = body.color
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError: