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
@@ -0,0 +1,34 @@
"""saved_views.source (ask 4: per-module saved views)
Saved "smart views" were feed-only; the Plex module now has them too. Add a `source` discriminator
(default "feed" so every existing row stays a feed view) and widen the per-user unique name constraint
to (user_id, source, name) so a feed view and a Plex view can share a name. `source` is NOT NULL with a
server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
small per-user table.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0073_saved_view_source"
down_revision: Union[str, None] = "0072_plex_person_bio"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"saved_views",
sa.Column("source", sa.String(length=16), nullable=False, server_default="feed"),
)
op.drop_constraint("uq_user_saved_view", "saved_views", type_="unique")
op.create_unique_constraint(
"uq_user_source_saved_view", "saved_views", ["user_id", "source", "name"]
)
def downgrade() -> None:
op.drop_constraint("uq_user_source_saved_view", "saved_views", type_="unique")
op.create_unique_constraint("uq_user_saved_view", "saved_views", ["user_id", "name"])
op.drop_column("saved_views", "source")
+9 -5
View File
@@ -646,19 +646,23 @@ class PlaylistItem(Base):
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user named snapshot of the feed's filter/sort state (a "smart view").
"""A per-user named snapshot of a module's filter/sort state (a "smart view").
`filters` is the serialized FeedFilters JSON the frontend applies with one click.
`position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
applied automatically on load — at most one per user, enforced by the API on set."""
`source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's
serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar
(drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most
one per (user, source), enforced by the API on set. The name is unique per (user, source)."""
__tablename__ = "saved_views"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
__table_args__ = (
UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE")
)
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
name: Mapped[str] = mapped_column(String(80))
filters: Mapped[dict] = mapped_column(JSON)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+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
+75
View File
@@ -0,0 +1,75 @@
"""ask 4: saved views are per-module (`source`). Feed and Plex views are separate lists, may share a
name, and each has its own single default. DB lane (`db` fixture) — skipped without Postgres.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import User
def _client(db, user, monkeypatch):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user # require_human resolves through this
return TestClient(app)
def _user(db):
u = User(email="views@example.com")
db.add(u)
db.commit()
return u
def test_views_are_scoped_by_source(db, monkeypatch):
user = _user(db)
try:
c = _client(db, user, monkeypatch)
# Same NAME under two sources is allowed (unique is per user+source now).
f = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"})
p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
feed = c.get("/api/saved-views?source=feed").json()
plex = c.get("/api/saved-views?source=plex").json()
assert [v["name"] for v in feed] == ["Fresh"]
assert [v["name"] for v in plex] == ["Fresh"]
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
# A source-less GET keeps the old feed-only behaviour.
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
finally:
app.dependency_overrides.clear()
def test_default_is_per_source(db, monkeypatch):
user = _user(db)
try:
c = _client(db, user, monkeypatch)
fid = c.post(
"/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}
).json()["id"]
pid = c.post(
"/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}
).json()["id"]
c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
# Making the Plex view default must NOT clear the feed view's default (different source).
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
finally:
app.dependency_overrides.clear()
def test_duplicate_name_within_a_source_conflicts(db, monkeypatch):
user = _user(db)
try:
c = _client(db, user, monkeypatch)
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
assert a.status_code == 200
assert b.status_code == 409 # same (user, source, name)
finally:
app.dependency_overrides.clear()