The demo account — one shared user that whitelisted emails entered from the login page, reset on a schedule — goes end to end (owner decision, E34): demo.py and the reset job, users.is_demo and demo_whitelist (migration 0098 deletes the demo user, its audit rows, the reset job's override and notices, then drops the column and table; the downgrade restores the empty schema), the /auth/demo entry, the admin whitelist and reset, the Demo tab, the banner, the login page's "Try the demo", and every is_demo branch in routes, userscope, scheduler and the UI. require_human only ever refused the demo account, so its 87 uses become current_user (owner-approved); require_write_scope keeps its own check. The login page's "or" divider now shows only beside the Google button: without the demo entry under it, an instance without Google would have said "or" to nothing. A contract test (removedFeatures) fails on the word anywhere in the backend, frontend, e2e or scripts — seen red on a planted hit. Size ceilings tightened for the modules that shrank; admin.py is back under the limit.
195 lines
5.8 KiB
Python
195 lines
5.8 KiB
Python
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, field_validator
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.models import SavedView, User
|
|
from app.routes._common import owned_or_404
|
|
from app.refusal import refused
|
|
|
|
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
|
|
# `source`).
|
|
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 {
|
|
"id": v.id,
|
|
"name": v.name,
|
|
"filters": v.filters or {},
|
|
"position": v.position,
|
|
"is_default": v.is_default,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
def list_views(
|
|
source: str = "feed",
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> list[dict]:
|
|
source = _norm_source(source)
|
|
rows = (
|
|
db.execute(
|
|
select(SavedView)
|
|
.where(SavedView.user_id == user.id, SavedView.source == source)
|
|
.order_by(SavedView.position, SavedView.id)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [_serialize(v) for v in rows]
|
|
|
|
|
|
# A feed view's tag pick is read by more than the feed: the autotag job keeps every tag a view names
|
|
# (sync/autotag.py). Held here to the shape the feed sends — a list of tag ids, at most this many — so
|
|
# a hand-made body (`"tags": 5`, or 60k ids) can't break that job for every user (E32 review).
|
|
_MAX_VIEW_TAGS = 500
|
|
|
|
|
|
def _check_filters(filters: dict | None) -> dict | None:
|
|
tags = (filters or {}).get("tags")
|
|
if tags is None:
|
|
return filters
|
|
if not isinstance(tags, list) or len(tags) > _MAX_VIEW_TAGS:
|
|
raise ValueError(f"filters.tags must be a list of at most {_MAX_VIEW_TAGS} tag ids")
|
|
if any(type(t) is not int for t in tags): # `type`, not isinstance: True is not a tag id
|
|
raise ValueError("filters.tags must hold tag ids")
|
|
return filters
|
|
|
|
|
|
class CreateViewIn(BaseModel):
|
|
name: str
|
|
filters: dict
|
|
source: str = "feed"
|
|
|
|
_filters = field_validator("filters")(_check_filters)
|
|
|
|
|
|
@router.post("")
|
|
def create_view(
|
|
body: CreateViewIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
name = body.name.strip()
|
|
if not name:
|
|
raise refused(400, "name_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.source == source
|
|
)
|
|
).scalar_one()
|
|
view = SavedView(
|
|
user_id=user.id,
|
|
source=source,
|
|
name=name[:80],
|
|
filters=body.filters,
|
|
position=maxpos + 1,
|
|
)
|
|
db.add(view)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise refused(409, "view_name_taken", name=name) from None
|
|
return _serialize(view)
|
|
|
|
|
|
class UpdateViewIn(BaseModel):
|
|
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
|
|
# must be a real object — an explicit null is rejected (a view can't have null filters).
|
|
name: str | None = None
|
|
filters: dict | None = None
|
|
is_default: bool | None = None
|
|
|
|
_filters = field_validator("filters")(_check_filters)
|
|
|
|
|
|
@router.patch("/{view_id}")
|
|
def update_view(
|
|
view_id: int,
|
|
body: UpdateViewIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
|
sent = body.model_fields_set
|
|
if "name" in sent:
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise refused(400, "name_required")
|
|
view.name = name[:80]
|
|
if "filters" in sent:
|
|
if body.filters is None:
|
|
raise refused(400, "filters_not_object")
|
|
view.filters = body.filters
|
|
if "is_default" in sent:
|
|
make_default = bool(body.is_default)
|
|
if make_default:
|
|
# 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.source == view.source,
|
|
SavedView.id != view.id,
|
|
)
|
|
.values(is_default=False)
|
|
)
|
|
view.is_default = make_default
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise refused(409, "view_name_taken", name=view.name) from None
|
|
return _serialize(view)
|
|
|
|
|
|
@router.delete("/{view_id}")
|
|
def delete_view(
|
|
view_id: int,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
view = owned_or_404(db, SavedView, view_id, user, what="view")
|
|
db.delete(view)
|
|
db.commit()
|
|
return {"deleted": view_id}
|
|
|
|
|
|
class ReorderViewsIn(BaseModel):
|
|
ids: list[int]
|
|
|
|
|
|
@router.post("/reorder")
|
|
def reorder_views(
|
|
body: ReorderViewsIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
ids = body.ids
|
|
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}
|