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