feat(views): saved-views backend — table, model, CRUD + reorder API

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).
This commit is contained in:
npeter83 2026-07-01 03:17:36 +02:00
parent 669994cc85
commit 3056734231
4 changed files with 235 additions and 0 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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).

View file

@ -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}