E32 review round 1 (B5, B6).
- The autotag orphan cleanup reads every user's feed views. A view whose tag pick was not a list
("tags": 5) raised for everyone's job, True passed as tag id 1, and ~66k ids pushed the NOT IN
past psycopg's 65535 bind parameters. The views are now read defensively and the kept ids
filtered in Python; the saved-views route holds filters.tags to a list of at most 500 ids, on
create and on update.
- tags_by_category resolved any tag id, so another user's private tag landed in its real category
and the feed summary answered differently than for an id that does not exist, which told the
caller which foreign ids exist. It is owner-scoped now (pre-existing; S2's missing-tag branch
made the difference visible).
196 lines
5.9 KiB
Python
196 lines
5.9 KiB
Python
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, field_validator
|
|
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]
|
|
|
|
|
|
# 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 (E32 review).
|
|
_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(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) 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(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) from None
|
|
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}
|