from typing import TYPE_CHECKING from fastapi import APIRouter, Depends from pydantic import BaseModel, field_validator from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db from app.models import User from app.reader import queries, service from app.routes._common import not_found from app.refusal import refused if TYPE_CHECKING: from app.reader.models import SavedView # Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by # `source`). 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, } def _own_view(db: Session, user: User, view_id: int) -> "SavedView": view = queries.own_view(db, user, view_id) if view is None: raise not_found("view") return view @router.get("") def list_views( source: str = "feed", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> list[dict]: return [_serialize(v) for v in queries.views(db, user, _norm_source(source))] # A feed view's tag pick is read by more than the feed: the autotag job keeps every tag a view names # (sync/autotag.py). Held here to the shape the feed sends — a list of tag ids, at most this many — so # a hand-made body (`"tags": 5`, or 60k ids) can't break that job for every user. _MAX_VIEW_TAGS = 500 def _check_filters(filters: dict | None) -> dict | None: tags = (filters or {}).get("tags") if tags is None: return filters if not isinstance(tags, list) or len(tags) > _MAX_VIEW_TAGS: raise ValueError(f"filters.tags must be a list of at most {_MAX_VIEW_TAGS} tag ids") if any(type(t) is not int for t in tags): # `type`, not isinstance: True is not a tag id raise ValueError("filters.tags must hold tag ids") return filters class CreateViewIn(BaseModel): name: str filters: dict source: str = "feed" _filters = field_validator("filters")(_check_filters) @router.post("") def create_view( body: CreateViewIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: name = body.name.strip() if not name: raise refused(400, "name_required") try: view = service.create_view(db, user, _norm_source(body.source), name, body.filters) db.commit() except IntegrityError: db.rollback() raise refused(409, "view_name_taken", name=name) from None 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 _filters = field_validator("filters")(_check_filters) @router.patch("/{view_id}") def update_view( view_id: int, body: UpdateViewIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: view = _own_view(db, user, view_id) sent = body.model_fields_set name = (body.name or "").strip() if "name" in sent and not name: raise refused(400, "name_required") if "filters" in sent and body.filters is None: raise refused(400, "filters_not_object") try: service.update_view( db, view, sent=sent, name=name, filters=body.filters, is_default=body.is_default ) db.commit() except IntegrityError: db.rollback() raise refused(409, "view_name_taken", name=view.name) from None return _serialize(view) @router.delete("/{view_id}") def delete_view( view_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: service.delete_view(db, _own_view(db, user, view_id)) db.commit() return {"deleted": view_id} class ReorderViewsIn(BaseModel): ids: list[int] @router.post("/reorder") def reorder_views( body: ReorderViewsIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: service.reorder_views(db, user, body.ids) db.commit() return {"ok": True}