siftlode/backend/app/routes/saved_views.py

153 lines
4.4 KiB
Python
Raw Normal View History

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}