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:
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user