feat(saved-views): scope saved views by module source (backend)

Saved "smart views" were feed-only; the Plex module is getting them too. Add a
`source` discriminator to saved_views ("feed" | "plex"), default "feed" so every
existing row and any source-less client stays feed. Widen the per-user unique
name constraint to (user_id, source, name) so a feed view and a Plex view can
share a name, and scope create's position numbering + the is_default "clear the
others" to (user, source).

Migration 0073 (ADD source NOT NULL server_default 'feed' + swap the unique
constraint; reversible). Endpoints: GET/POST take `source` (default feed).
Tests: source isolation, per-source default, per-source name uniqueness.
This commit is contained in:
2026-08-22 18:56:52 +02:00
parent f983a491c0
commit fa7a09a420
4 changed files with 149 additions and 14 deletions
+31 -9
View File
@@ -9,11 +9,19 @@ 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 the feed's FeedFilters. Gated behind
# require_human so the shared demo account can't pollute a communal list (mirrors how other
# personal-state writes are scoped).
# 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 {
@@ -27,12 +35,15 @@ def _serialize(v: SavedView) -> dict:
@router.get("")
def list_views(
user: User = Depends(require_human), db: Session = Depends(get_db)
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)
.where(SavedView.user_id == user.id, SavedView.source == source)
.order_by(SavedView.position, SavedView.id)
)
.scalars()
@@ -44,6 +55,7 @@ def list_views(
class CreateViewIn(BaseModel):
name: str
filters: dict
source: str = "feed"
@router.post("")
@@ -55,13 +67,19 @@ def create_view(
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.user_id == user.id, SavedView.source == source
)
).scalar_one()
view = SavedView(
user_id=user.id, name=name[:80], filters=body.filters, position=maxpos + 1
user_id=user.id,
source=source,
name=name[:80],
filters=body.filters,
position=maxpos + 1,
)
db.add(view)
try:
@@ -107,10 +125,14 @@ def update_view(
if "is_default" in sent:
make_default = bool(body.is_default)
if make_default:
# At most one default per user: clear the flag on the others first.
# 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.id != view.id)
.where(
SavedView.user_id == user.id,
SavedView.source == view.source,
SavedView.id != view.id,
)
.values(is_default=False)
)
view.is_default = make_default