from fastapi import APIRouter, Depends, HTTPException 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 # 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] @router.post("") def create_view( payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: name = (payload.get("name") or "").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 ) 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: view = db.get(SavedView, view_id) if view is None or view.user_id != user.id: raise HTTPException(status_code=404, detail="Unknown view") return view @router.patch("/{view_id}") def update_view( view_id: int, payload: dict, 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() 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): 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")) 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} @router.post("/reorder") def reorder_views( payload: dict, 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") 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}