Files
siftlode/backend/app/routes/saved_views.py
T
peter e5c75702e8 fix(views,tags): a rename onto a taken name names the taken one, not the row's old name
The rollback after the conflict puts the old name back on the row, so the refusal read
"you already have a view named 'hh'" when the taken name was 'gaming'.
2026-10-29 23:07:54 +01:00

160 lines
4.7 KiB
Python

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()
# The name asked for: the rollback has put the view's old name back on `view`.
raise refused(409, "view_name_taken", name=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}