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:
parent
669994cc85
commit
3056734231
4 changed files with 235 additions and 0 deletions
59
backend/alembic/versions/0035_saved_views.py
Normal file
59
backend/alembic/versions/0035_saved_views.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
152
backend/app/routes/saved_views.py
Normal file
152
backend/app/routes/saved_views.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue