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:
2026-07-01 03:17:36 +02:00
parent 124260f3d2
commit fb719edc7b
4 changed files with 235 additions and 0 deletions
@@ -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")