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
+15 -8
View File
@@ -5,6 +5,7 @@ import re
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
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
@@ -571,14 +572,18 @@ def get_facets(
return {"counts": counts} return {"counts": counts}
class VideoStateIn(BaseModel):
status: str
@router.post("/videos/{video_id}/state") @router.post("/videos/{video_id}/state")
def set_video_state( def set_video_state(
video_id: str, video_id: str,
payload: dict, body: VideoStateIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
status = payload.get("status") status = body.status
if status not in VALID_STATES: if status not in VALID_STATES:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}")
if db.get(Video, video_id) is None: if db.get(Video, video_id) is None:
@@ -653,10 +658,15 @@ def clear_video_state(
return {"video_id": video_id, "status": "new", "position_seconds": 0} return {"video_id": video_id, "status": "new", "position_seconds": 0}
class VideoProgressIn(BaseModel):
position_seconds: int = 0
duration_seconds: int = 0
@router.post("/videos/{video_id}/progress") @router.post("/videos/{video_id}/progress")
def set_video_progress( def set_video_progress(
video_id: str, video_id: str,
payload: dict, body: VideoProgressIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -664,11 +674,8 @@ def set_video_progress(
Stores a per-user position on the video_states row without touching watch status, so a Stores a per-user position on the video_states row without touching watch status, so a
partially-watched video can render a progress bar and match the "in progress" filter. partially-watched video can render a progress bar and match the "in progress" filter.
Trivially-early and near-finished positions clear the position rather than store it.""" Trivially-early and near-finished positions clear the position rather than store it."""
try: position = body.position_seconds
position = int(payload.get("position_seconds") or 0) duration = body.duration_seconds
duration = int(payload.get("duration_seconds") or 0)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
if position < 0: if position < 0:
position = 0 position = 0
+32 -19
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, select, update from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -40,25 +41,27 @@ def list_views(
return [_serialize(v) for v in rows] return [_serialize(v) for v in rows]
class CreateViewIn(BaseModel):
name: str
filters: dict
@router.post("") @router.post("")
def create_view( def create_view(
payload: dict, body: CreateViewIn,
user: User = Depends(require_human), user: User = Depends(require_human),
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")
filters = payload.get("filters")
if not isinstance(filters, dict):
raise HTTPException(status_code=400, detail="filters must be an object")
maxpos = db.execute( maxpos = db.execute(
select(func.coalesce(func.max(SavedView.position), -1)).where( select(func.coalesce(func.max(SavedView.position), -1)).where(
SavedView.user_id == user.id SavedView.user_id == user.id
) )
).scalar_one() ).scalar_one()
view = SavedView( 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) db.add(view)
try: 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") 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}") @router.patch("/{view_id}")
def update_view( def update_view(
view_id: int, view_id: int,
payload: dict, body: UpdateViewIn,
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
view = _own_view(db, user, view_id) view = _own_view(db, user, view_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")
view.name = name[:80] view.name = name[:80]
if "filters" in payload: if "filters" in sent:
filters = payload.get("filters") if body.filters is None:
if not isinstance(filters, dict):
raise HTTPException(status_code=400, detail="filters must be an object") raise HTTPException(status_code=400, detail="filters must be an object")
view.filters = filters view.filters = body.filters
if "is_default" in payload: if "is_default" in sent:
make_default = bool(payload.get("is_default")) make_default = bool(body.is_default)
if make_default: if make_default:
# At most one default per user: clear the flag on the others first. # At most one default per user: clear the flag on the others first.
db.execute( db.execute(
@@ -125,15 +136,17 @@ def delete_view(
return {"deleted": view_id} return {"deleted": view_id}
class ReorderViewsIn(BaseModel):
ids: list[int]
@router.post("/reorder") @router.post("/reorder")
def reorder_views( def reorder_views(
payload: dict, body: ReorderViewsIn,
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
ids = payload.get("ids") ids = body.ids
if not isinstance(ids, list):
raise HTTPException(status_code=400, detail="ids must be a list")
rows = ( rows = (
db.execute(select(SavedView).where(SavedView.user_id == user.id)) db.execute(select(SavedView).where(SavedView.user_id == user.id))
.scalars() .scalars()