refactor(api): Pydantic request models for saved-views & feed state (R6 S1b)

- saved_views: CreateViewIn / UpdateViewIn / ReorderViewsIn. PATCH presence via
  model_fields_set; filters typed as dict (a non-object is now 422, an explicit
  null on update stays a 400 "filters must be an object").
- feed: VideoStateIn(status) / VideoProgressIn(position_seconds,duration_seconds) —
  the manual int()+400 in progress becomes a 422 on a non-number; the status
  membership 400 and the video 404 are unchanged.

Gate: ruff clean.
This commit is contained in:
2026-07-26 04:53:45 +02:00
parent a622470a28
commit 99d65cea23
2 changed files with 47 additions and 27 deletions
+32 -19
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -40,25 +41,27 @@ def list_views(
return [_serialize(v) for v in rows]
class CreateViewIn(BaseModel):
name: str
filters: dict
@router.post("")
def create_view(
payload: dict,
body: CreateViewIn,
user: User = Depends(require_human),
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")
filters = payload.get("filters")
if not isinstance(filters, dict):
raise HTTPException(status_code=400, detail="filters must be an object")
maxpos = db.execute(
select(func.coalesce(func.max(SavedView.position), -1)).where(
SavedView.user_id == user.id
)
).scalar_one()
view = SavedView(
user_id=user.id, name=name[:80], filters=filters, position=maxpos + 1
user_id=user.id, name=name[:80], filters=body.filters, position=maxpos + 1
)
db.add(view)
try:
@@ -75,26 +78,34 @@ def _own_view(db: Session, user: User, view_id: int) -> SavedView:
return owned_or_404(db, SavedView, view_id, user, detail="Unknown view")
class UpdateViewIn(BaseModel):
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
# must be a real object — an explicit null is rejected (a view can't have null filters).
name: str | None = None
filters: dict | None = None
is_default: bool | None = None
@router.patch("/{view_id}")
def update_view(
view_id: int,
payload: dict,
body: UpdateViewIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
view = _own_view(db, user, view_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")
view.name = name[:80]
if "filters" in payload:
filters = payload.get("filters")
if not isinstance(filters, dict):
if "filters" in sent:
if body.filters is None:
raise HTTPException(status_code=400, detail="filters must be an object")
view.filters = filters
if "is_default" in payload:
make_default = bool(payload.get("is_default"))
view.filters = body.filters
if "is_default" in sent:
make_default = bool(body.is_default)
if make_default:
# At most one default per user: clear the flag on the others first.
db.execute(
@@ -125,15 +136,17 @@ def delete_view(
return {"deleted": view_id}
class ReorderViewsIn(BaseModel):
ids: list[int]
@router.post("/reorder")
def reorder_views(
payload: dict,
body: ReorderViewsIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
ids = payload.get("ids")
if not isinstance(ids, list):
raise HTTPException(status_code=400, detail="ids must be a list")
ids = body.ids
rows = (
db.execute(select(SavedView).where(SavedView.user_id == user.id))
.scalars()