- 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.
164 lines
4.5 KiB
Python
164 lines
4.5 KiB
Python
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
|
|
|
|
from app.auth import require_human
|
|
from app.db import get_db
|
|
from app.models import SavedView, User
|
|
from app.routes._common import owned_or_404
|
|
|
|
# Saved "smart views": per-user named snapshots of the feed's FeedFilters. Gated behind
|
|
# require_human so the shared demo account can't pollute a communal list (mirrors how other
|
|
# personal-state writes are scoped).
|
|
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
|
|
|
|
|
|
def _serialize(v: SavedView) -> dict:
|
|
return {
|
|
"id": v.id,
|
|
"name": v.name,
|
|
"filters": v.filters or {},
|
|
"position": v.position,
|
|
"is_default": v.is_default,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
def list_views(
|
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
rows = (
|
|
db.execute(
|
|
select(SavedView)
|
|
.where(SavedView.user_id == user.id)
|
|
.order_by(SavedView.position, SavedView.id)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [_serialize(v) for v in rows]
|
|
|
|
|
|
class CreateViewIn(BaseModel):
|
|
name: str
|
|
filters: dict
|
|
|
|
|
|
@router.post("")
|
|
def create_view(
|
|
body: CreateViewIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
name = body.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="name is required")
|
|
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=body.filters, position=maxpos + 1
|
|
)
|
|
db.add(view)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=409, detail=f"You already have a view named “{name}”."
|
|
)
|
|
return _serialize(view)
|
|
|
|
|
|
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,
|
|
body: UpdateViewIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
view = _own_view(db, user, view_id)
|
|
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 sent:
|
|
if body.filters is None:
|
|
raise HTTPException(status_code=400, detail="filters must be an object")
|
|
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(
|
|
update(SavedView)
|
|
.where(SavedView.user_id == user.id, SavedView.id != view.id)
|
|
.values(is_default=False)
|
|
)
|
|
view.is_default = make_default
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=409, detail=f"You already have a view named “{view.name}”."
|
|
)
|
|
return _serialize(view)
|
|
|
|
|
|
@router.delete("/{view_id}")
|
|
def delete_view(
|
|
view_id: int,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
view = _own_view(db, user, view_id)
|
|
db.delete(view)
|
|
db.commit()
|
|
return {"deleted": view_id}
|
|
|
|
|
|
class ReorderViewsIn(BaseModel):
|
|
ids: list[int]
|
|
|
|
|
|
@router.post("/reorder")
|
|
def reorder_views(
|
|
body: ReorderViewsIn,
|
|
user: User = Depends(require_human),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
ids = body.ids
|
|
rows = (
|
|
db.execute(select(SavedView).where(SavedView.user_id == user.id))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
by_id = {v.id: v for v in rows}
|
|
pos = 0
|
|
for vid in ids:
|
|
v = by_id.get(vid)
|
|
if v is not None:
|
|
v.position = pos
|
|
pos += 1
|
|
db.commit()
|
|
return {"ok": True}
|