from fastapi import APIRouter, Depends 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 from app.refusal import refused # Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by # `source`). 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"]) # The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller # (or an older client) that omits `source` keeps the original feed behaviour. _SOURCES = {"feed", "plex"} def _norm_source(source: str) -> str: return source if source in _SOURCES else "feed" 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( source: str = "feed", user: User = Depends(require_human), db: Session = Depends(get_db), ) -> list[dict]: source = _norm_source(source) rows = ( db.execute( select(SavedView) .where(SavedView.user_id == user.id, SavedView.source == source) .order_by(SavedView.position, SavedView.id) ) .scalars() .all() ) return [_serialize(v) for v in rows] class CreateViewIn(BaseModel): name: str filters: dict source: str = "feed" @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 refused(400, "name_required") source = _norm_source(body.source) # Position is per (user, source), so each module's views number 0..N independently. maxpos = db.execute( select(func.coalesce(func.max(SavedView.position), -1)).where( SavedView.user_id == user.id, SavedView.source == source ) ).scalar_one() view = SavedView( user_id=user.id, source=source, name=name[:80], filters=body.filters, position=maxpos + 1, ) db.add(view) try: db.commit() except IntegrityError: db.rollback() raise refused(409, "view_name_taken", name=name) return _serialize(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 = owned_or_404(db, SavedView, view_id, user, what="view") sent = body.model_fields_set if "name" in sent: name = (body.name or "").strip() if not name: raise refused(400, "name_required") view.name = name[:80] if "filters" in sent: if body.filters is None: raise refused(400, "filters_not_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, source): clear the flag on this module's others first. db.execute( update(SavedView) .where( SavedView.user_id == user.id, SavedView.source == view.source, SavedView.id != view.id, ) .values(is_default=False) ) view.is_default = make_default try: db.commit() except IntegrityError: db.rollback() raise refused(409, "view_name_taken", name=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 = owned_or_404(db, SavedView, view_id, user, what="view") 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}