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'.
160 lines
4.7 KiB
Python
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}
|