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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user