From 30567342313b6fe91ed82cd1444b552b5dec3cbb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 03:17:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(views):=20saved-views=20backend=20?= =?UTF-8?q?=E2=80=94=20table,=20model,=20CRUD=20+=20reorder=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-user named snapshots of the feed's FeedFilters. New saved_views table (migration 0035, unique per user+name, position, is_default) + SavedView model, and a /api/saved-views router (list/create/patch/delete/reorder) gated behind require_human so the shared demo account can't pollute it. Setting is_default clears the flag on the user's other views (one default). --- backend/alembic/versions/0035_saved_views.py | 59 +++++++ backend/app/main.py | 2 + backend/app/models.py | 22 +++ backend/app/routes/saved_views.py | 152 +++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 backend/alembic/versions/0035_saved_views.py create mode 100644 backend/app/routes/saved_views.py diff --git a/backend/alembic/versions/0035_saved_views.py b/backend/alembic/versions/0035_saved_views.py new file mode 100644 index 0000000..4a43a80 --- /dev/null +++ b/backend/alembic/versions/0035_saved_views.py @@ -0,0 +1,59 @@ +"""per-user saved smart views + +Revision ID: 0035_saved_views +Revises: 0034_blocked_channels +Create Date: 2026-07-01 + +Adds the `saved_views` table: per-user named snapshots of the feed's filter/sort state. Each +row stores the serialized FeedFilters (JSON), a sidebar `position` for drag-reorder, and an +`is_default` flag (at most one per user, enforced by the API) for the view applied on load. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0035_saved_views" +down_revision: Union[str, None] = "0034_blocked_channels" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "saved_views", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False), + sa.Column("filters", sa.JSON(), nullable=False), + sa.Column( + "position", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "is_default", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="uq_user_saved_view"), + ) + op.create_index("ix_saved_views_user_id", "saved_views", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_saved_views_user_id", table_name="saved_views") + op.drop_table("saved_views") diff --git a/backend/app/main.py b/backend/app/main.py index a404305..944e294 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -38,6 +38,7 @@ from app.routes import ( notifications, playlists, quota, + saved_views, scheduler as scheduler_routes, search as search_routes, setup as setup_routes, @@ -120,6 +121,7 @@ app.include_router(notifications.router) app.include_router(messages.router) app.include_router(channels.router) app.include_router(playlists.router) +app.include_router(saved_views.router) app.include_router(admin.router) app.include_router(config_routes.router) app.include_router(scheduler_routes.router) diff --git a/backend/app/models.py b/backend/app/models.py index 5788589..3160514 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -593,6 +593,28 @@ class PlaylistItem(Base): playlist: Mapped["Playlist"] = relationship(back_populates="items") +class SavedView(Base, TimestampMixin, UpdatedAtMixin): + """A per-user named snapshot of the feed'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.""" + + __tablename__ = "saved_views" + __table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(80)) + filters: Mapped[dict] = mapped_column(JSON) + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + is_default: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + + class Notification(Base): """A durable, server-backed per-user notification (the inbox center, phase 1). diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py new file mode 100644 index 0000000..dd8a41e --- /dev/null +++ b/backend/app/routes/saved_views.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException +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 + +# 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). +router = APIRouter(prefix="/api/saved-views", tags=["saved-views"]) + + +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( + user: User = Depends(require_human), db: Session = Depends(get_db) +) -> list[dict]: + rows = ( + db.execute( + select(SavedView) + .where(SavedView.user_id == user.id) + .order_by(SavedView.position, SavedView.id) + ) + .scalars() + .all() + ) + return [_serialize(v) for v in rows] + + +@router.post("") +def create_view( + payload: dict, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + filters = payload.get("filters") + if not isinstance(filters, dict): + raise HTTPException(status_code=400, detail="filters must be an object") + maxpos = db.execute( + select(func.coalesce(func.max(SavedView.position), -1)).where( + SavedView.user_id == user.id + ) + ).scalar_one() + view = SavedView( + user_id=user.id, name=name[:80], filters=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) + + +def _own_view(db: Session, user: User, view_id: int) -> SavedView: + view = db.get(SavedView, view_id) + if view is None or view.user_id != user.id: + raise HTTPException(status_code=404, detail="Unknown view") + return view + + +@router.patch("/{view_id}") +def update_view( + view_id: int, + payload: dict, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + view = _own_view(db, user, view_id) + if "name" in payload: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name cannot be empty") + view.name = name[:80] + if "filters" in payload: + filters = payload.get("filters") + if not isinstance(filters, dict): + raise HTTPException(status_code=400, detail="filters must be an object") + view.filters = filters + if "is_default" in payload: + make_default = bool(payload.get("is_default")) + if make_default: + # At most one default per user: clear the flag on the others first. + db.execute( + update(SavedView) + .where(SavedView.user_id == user.id, 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 = _own_view(db, user, view_id) + db.delete(view) + db.commit() + return {"deleted": view_id} + + +@router.post("/reorder") +def reorder_views( + payload: dict, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + ids = payload.get("ids") + if not isinstance(ids, list): + raise HTTPException(status_code=400, detail="ids must be a list") + 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}