move_item checked "already in the target?" and then re-parented without a lock, so a second tab's add or the scheduler's YouTube merge committing the same video in between hit uq_playlist_video and answered a raw 500. The re-parent now runs in a savepoint and a conflict falls back to the already-present outcome. The target is touched only when the row landed in it (an untouched list was promoted in the rail's "recently changed" sort), and _touch stamps the database clock like the mixin's own onupdate. The canonical-patterns table names owned_or_404 against ad-hoc ownership helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view, _own_tag, notifications._owned, downloads._own_job), two of them hand-rolled with a bare English 404. All call sites now use owned_or_404, and the download/link/notification 404s are structured like the rest. The contract test reads every "what is gone" the routes name, and the YouTube refusal reasons, and requires both languages to name each.
174 lines
5.1 KiB
Python
174 lines
5.1 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
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
|
|
|
|
# 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]
|
|
|
|
|
|
class CreateViewIn(BaseModel):
|
|
name: str
|
|
filters: dict
|
|
source: str = "feed"
|
|
|
|
|
|
@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 HTTPException(status_code=400, detail="name is 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 HTTPException(status_code=409, detail=f"You already have a view named “{name}”.")
|
|
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
|
|
|
|
|
|
@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 HTTPException(status_code=400, detail="name cannot be empty")
|
|
view.name = name[:80]
|
|
if "filters" in sent:
|
|
if body.filters is None:
|
|
raise HTTPException(status_code=400, detail="filters must be an 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 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 = 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}
|