From fa7a09a420afa1312f2d92edc11398a87e5b03dc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 18:56:52 +0200 Subject: [PATCH] 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. --- .../versions/0073_saved_view_source.py | 34 +++++++++ backend/app/models.py | 14 ++-- backend/app/routes/saved_views.py | 40 +++++++--- backend/tests/test_saved_views_source.py | 75 +++++++++++++++++++ 4 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 backend/alembic/versions/0073_saved_view_source.py create mode 100644 backend/tests/test_saved_views_source.py diff --git a/backend/alembic/versions/0073_saved_view_source.py b/backend/alembic/versions/0073_saved_view_source.py new file mode 100644 index 00000000..c2aa8843 --- /dev/null +++ b/backend/alembic/versions/0073_saved_view_source.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index e449b185..18238538 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py index f9bb8f66..2efe166a 100644 --- a/backend/app/routes/saved_views.py +++ b/backend/app/routes/saved_views.py @@ -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 diff --git a/backend/tests/test_saved_views_source.py b/backend/tests/test_saved_views_source.py new file mode 100644 index 00000000..0c3bad19 --- /dev/null +++ b/backend/tests/test_saved_views_source.py @@ -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()