Saved layouts feature
This commit is contained in:
@@ -149,6 +149,47 @@ HTML can't carry executable `<script>` tags. While a dialog is open,
|
||||
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
|
||||
for the always-present header/status-bar JS.
|
||||
|
||||
## Saved layouts
|
||||
|
||||
A user can snapshot a frame's whole widget arrangement -- every widget's
|
||||
type/placement/settings, calendar/task sources, and button-action
|
||||
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
|
||||
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
|
||||
then switch back to it later, or apply it to a *different* frame. Saved
|
||||
layouts are owned by the **user**, not any one frame -- the same set
|
||||
shows up (with a per-frame `compatible` flag) on every frame that user
|
||||
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
|
||||
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
|
||||
captured from.
|
||||
|
||||
Saving only captures an authored *setting*, never runtime/cache state --
|
||||
a photo widget's current queue position, a calendar's fetch cache, a
|
||||
whiteboard's rendered-image cache, etc. are deliberately left out (see
|
||||
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
|
||||
`widget_type`), so applying a layout feels like a fresh widget of that
|
||||
type with its settings pre-filled, not a resurrection of stale state
|
||||
from whenever it was saved. A static-image widget's uploaded bytes are
|
||||
the one exception carried through verbatim (`SavedLayoutWidget.image`).
|
||||
Saving again under a name the user already has overwrites that layout's
|
||||
snapshot in place rather than erroring or creating a duplicate --
|
||||
`SavedLayout`'s own docstring.
|
||||
|
||||
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
|
||||
deletes every widget currently on that frame and recreates the saved
|
||||
arrangement from scratch, remapping calendar/task sources and button
|
||||
bindings onto the newly-created widget ids -- same "act unconditionally
|
||||
on the server, confirm on the client" posture as the Layout tab's own
|
||||
"Clear all". A source whose owning user account no longer exists is
|
||||
silently dropped rather than left dangling (config is JSON, not
|
||||
FK-checked, so nothing else would catch that).
|
||||
|
||||
The web UI lives in the Layout tab's "Saved layouts" card
|
||||
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
|
||||
`PATCH`/`DELETE /api/layouts/{id}`, `POST
|
||||
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
|
||||
saved layouts each with Apply/rename/delete, incompatible ones shown
|
||||
greyed-out with a "different orientation" badge rather than hidden.
|
||||
|
||||
## Known gaps (Phase 6, not yet done)
|
||||
|
||||
The original 8-phase rollout plan's last phase is still open:
|
||||
|
||||
+4
-1
@@ -5,6 +5,8 @@ This module is assembly only -- routes live in app/routers/:
|
||||
device.py the firmware-facing /frame/* protocol (paths frozen)
|
||||
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
||||
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
|
||||
api_layouts.py named, user-owned saved layouts, /api/layouts,
|
||||
/api/frames/{id}/layouts
|
||||
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
|
||||
pages.py setup/login/claim/settings/admin
|
||||
manage.py the limited manage-QR surface (/m/, /api/m/)
|
||||
@@ -31,7 +33,7 @@ from .auth import (
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import Frame
|
||||
from .routers import api_frames, api_widgets, device, frame_pages, manage, pages
|
||||
from .routers import api_frames, api_layouts, api_widgets, device, frame_pages, manage, pages
|
||||
from .routers.common import shell_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,6 +49,7 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(device.router)
|
||||
app.include_router(api_frames.router)
|
||||
app.include_router(api_widgets.router)
|
||||
app.include_router(api_layouts.router)
|
||||
app.include_router(frame_pages.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(manage.router)
|
||||
|
||||
@@ -558,6 +558,72 @@ def _migration_22(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN font_family TEXT NOT NULL DEFAULT 'sans'"))
|
||||
|
||||
|
||||
def _migration_23(conn) -> None:
|
||||
"""New feature: named, user-owned saved layouts (see models.
|
||||
SavedLayout/SavedLayoutWidget/SavedLayoutSource/
|
||||
SavedLayoutButtonAction, routers/api_layouts.py) -- a snapshot of a
|
||||
frame's widget arrangement a user can capture and later apply to any
|
||||
frame they control whose grid matches, instead of manually rebuilding
|
||||
it widget by widget.
|
||||
|
||||
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
||||
migration 20/21's own comments: create_all always reflects models.py's
|
||||
CURRENT shape, so replaying the full chain on an old database could
|
||||
collide with a later migration's ALTER TABLE on one of these same
|
||||
tables."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE saved_layouts ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
||||
"name TEXT NOT NULL, "
|
||||
"cols INTEGER NOT NULL, "
|
||||
"rows INTEGER NOT NULL, "
|
||||
"created_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"updated_at REAL NOT NULL DEFAULT 0.0)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE UNIQUE INDEX ix_saved_layouts_user_name ON saved_layouts (user_id, name)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE saved_layout_widgets ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"saved_layout_id INTEGER NOT NULL REFERENCES saved_layouts(id) ON DELETE CASCADE, "
|
||||
"widget_type TEXT NOT NULL, "
|
||||
"x INTEGER NOT NULL, y INTEGER NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL, "
|
||||
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
||||
"config TEXT NOT NULL DEFAULT '{}', "
|
||||
"image BLOB)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE INDEX ix_saved_layout_widgets_layout ON saved_layout_widgets (saved_layout_id)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE saved_layout_sources ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
||||
"kind TEXT NOT NULL, "
|
||||
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
||||
"calendar_key TEXT NOT NULL, "
|
||||
"calendar_label TEXT NOT NULL DEFAULT '', "
|
||||
"included INTEGER NOT NULL DEFAULT 1, "
|
||||
"color_index INTEGER)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE INDEX ix_saved_layout_sources_widget ON saved_layout_sources (saved_layout_widget_id)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE saved_layout_button_actions ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
||||
"button TEXT NOT NULL, "
|
||||
"action TEXT NOT NULL, "
|
||||
"sort_order INTEGER NOT NULL DEFAULT 0)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE INDEX ix_saved_layout_button_actions_widget ON saved_layout_button_actions (saved_layout_widget_id)"
|
||||
))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -581,6 +647,7 @@ MIGRATIONS = [
|
||||
(20, _migration_20),
|
||||
(21, _migration_21),
|
||||
(22, _migration_22),
|
||||
(23, _migration_23),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -648,6 +648,109 @@ class FrameButtonAction(Base):
|
||||
)
|
||||
|
||||
|
||||
class SavedLayout(Base):
|
||||
"""A named snapshot of one frame's widget arrangement (types,
|
||||
placement, per-widget settings, button assignments) -- owned by a
|
||||
*user*, not a frame, so it can be applied to any frame that user
|
||||
controls whose grid matches (see docs/widgets.md's "Saved layouts").
|
||||
cols/rows is the grid.grid_dims(orientation) the snapshot was taken
|
||||
at -- an 8x5 (landscape-class) layout isn't meaningful on a 5x8
|
||||
(portrait-class) frame, same reasoning as grid.py's own orientation-
|
||||
change note.
|
||||
|
||||
Saving again with a name that already exists for this user
|
||||
overwrites that layout's snapshot in place (see routers/
|
||||
api_layouts.py's api_layout_save) rather than erroring or quietly
|
||||
creating a second layout with the same name -- the "named save slot"
|
||||
behavior people expect."""
|
||||
|
||||
__tablename__ = "saved_layouts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
cols: Mapped[int] = mapped_column(Integer)
|
||||
rows: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
updated_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_saved_layouts_user_name", "user_id", "name", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class SavedLayoutWidget(Base):
|
||||
"""One captured widget's type/placement/settings within a
|
||||
SavedLayout -- the snapshot analogue of Widget plus its per-type
|
||||
config row, minus anything that's runtime/cache state rather than an
|
||||
authored setting (a photo widget's current queue position, a
|
||||
calendar's fetch cache, a whiteboard's rendered-image cache, etc.)
|
||||
-- see api_layouts.LAYOUT_CONFIG_FIELDS for the exact per-type field
|
||||
allowlist. `config` holds every JSON-safe captured setting; `image`
|
||||
is only ever populated for a static-image widget's uploaded bytes
|
||||
(its own BLOB column rather than folding base64 into the JSON, same
|
||||
reasoning as StaticWidgetConfig.image itself)."""
|
||||
|
||||
__tablename__ = "saved_layout_widgets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
saved_layout_id: Mapped[int] = mapped_column(ForeignKey("saved_layouts.id", ondelete="CASCADE"))
|
||||
widget_type: Mapped[str] = mapped_column(String)
|
||||
x: Mapped[int] = mapped_column(Integer)
|
||||
y: Mapped[int] = mapped_column(Integer)
|
||||
w: Mapped[int] = mapped_column(Integer)
|
||||
h: Mapped[int] = mapped_column(Integer)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
__table_args__ = (Index("ix_saved_layout_widgets_layout", "saved_layout_id"),)
|
||||
|
||||
|
||||
class SavedLayoutSource(Base):
|
||||
"""One included calendar/task-list source captured on a calendar or
|
||||
tasks SavedLayoutWidget -- the snapshot analogue of FrameCalendar/
|
||||
FrameTaskList. `kind` ("calendar" | "task") distinguishes which,
|
||||
since both shapes are otherwise identical and sharing one table
|
||||
avoids a near-duplicate SavedLayoutTaskSource table."""
|
||||
|
||||
__tablename__ = "saved_layout_sources"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
saved_layout_widget_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
calendar_key: Mapped[str] = mapped_column(String)
|
||||
calendar_label: Mapped[str] = mapped_column(String, default="")
|
||||
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||
|
||||
__table_args__ = (Index("ix_saved_layout_sources_widget", "saved_layout_widget_id"),)
|
||||
|
||||
|
||||
class SavedLayoutButtonAction(Base):
|
||||
"""One (button, action) binding captured for one SavedLayoutWidget --
|
||||
the snapshot analogue of FrameButtonAction. References the captured
|
||||
widget directly rather than a frame_id/widget_id pair (neither
|
||||
exists until the layout is applied) so applying can remap it onto
|
||||
whichever new Widget row that captured widget becomes -- see
|
||||
routers/api_layouts.py's api_layout_apply."""
|
||||
|
||||
__tablename__ = "saved_layout_button_actions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
saved_layout_widget_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
|
||||
)
|
||||
button: Mapped[str] = mapped_column(String)
|
||||
action: Mapped[str] = mapped_column(String)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
__table_args__ = (Index("ix_saved_layout_button_actions_widget", "saved_layout_widget_id"),)
|
||||
|
||||
|
||||
class PendingClaim(Base):
|
||||
"""A claim submitted before the frame's first check-in (the user beat
|
||||
the device to the server after provisioning). Attached automatically
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Saved layouts: a named snapshot of one frame's widget arrangement
|
||||
(types, placement, per-widget settings, button assignments) that a user
|
||||
can capture and later apply to any frame they control whose grid matches
|
||||
-- see models.SavedLayout and docs/widgets.md's "Saved layouts" section.
|
||||
|
||||
Layouts are owned by a *user*, not a frame (SavedLayout.user_id), so
|
||||
every endpoint here except save/apply/list (which need a frame_id to act
|
||||
on) is frame-agnostic -- /api/layouts/{id} rather than /api/frames/{fid}/
|
||||
layouts/{id}, gated purely on "is this your own saved layout" rather than
|
||||
frame view/control.
|
||||
|
||||
Save/apply DO live under /api/frames/{frame_id}/... (require_frame_
|
||||
control) since they read/replace one specific frame's actual widgets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import grid
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..models import (
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
SavedLayout,
|
||||
SavedLayoutButtonAction,
|
||||
SavedLayoutSource,
|
||||
SavedLayoutWidget,
|
||||
User,
|
||||
WIDGET_CONFIG_MODELS,
|
||||
Widget,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_LAYOUT_NAME_LEN = 60
|
||||
|
||||
# Per-widget-type allowlist of config columns that are an actual authored
|
||||
# *setting* (captured/restored by a saved layout) as opposed to runtime/
|
||||
# cache state (a photo widget's current queue position, a calendar's
|
||||
# fetch cache, a whiteboard's rendered-image cache, ...) which a layout
|
||||
# deliberately leaves out -- applying a layout should feel like a fresh
|
||||
# widget of that type with these settings pre-filled, not a resurrection
|
||||
# of stale queue/cache state from whenever it was saved. "static"
|
||||
# excludes `image` on purpose -- that BLOB lives on SavedLayoutWidget.image
|
||||
# instead (see its own model docstring).
|
||||
LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
|
||||
"calendar": (
|
||||
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
|
||||
"week_days", "week_layout", "week_start_offset",
|
||||
),
|
||||
"tasks": ("name", "show_completed"),
|
||||
"static": ("display_mode", "original_filename"),
|
||||
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
||||
"whiteboard": ("user_id", "url"),
|
||||
}
|
||||
|
||||
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
||||
SOURCE_MODELS: dict[str, tuple[type, str]] = {
|
||||
"calendar": (FrameCalendar, "calendar"),
|
||||
"tasks": (FrameTaskList, "task"),
|
||||
}
|
||||
|
||||
|
||||
def _layout_summary(db: Session, layout: SavedLayout, frame: Frame | None = None) -> dict:
|
||||
widget_count = db.scalar(
|
||||
select(func.count()).select_from(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id)
|
||||
)
|
||||
result = {
|
||||
"id": layout.id,
|
||||
"name": layout.name,
|
||||
"cols": layout.cols,
|
||||
"rows": layout.rows,
|
||||
"widget_count": widget_count or 0,
|
||||
"created_at": layout.created_at,
|
||||
"updated_at": layout.updated_at,
|
||||
}
|
||||
if frame is not None:
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
result["compatible"] = (layout.cols, layout.rows) == (cols, rows)
|
||||
return result
|
||||
|
||||
|
||||
def _user_owned_layout(db: Session, layout_id: int, user: User) -> SavedLayout:
|
||||
layout = db.get(SavedLayout, layout_id)
|
||||
if layout is None or layout.user_id != user.id:
|
||||
raise HTTPException(404, "No such saved layout")
|
||||
return layout
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/layouts")
|
||||
def api_layouts_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""Every saved layout owned by the calling user (saved layouts are
|
||||
global to a user, not scoped to this or any other frame) -- includes
|
||||
a `compatible` flag per layout for whether it can actually be applied
|
||||
to *this* frame's current grid, so the UI can offer incompatible ones
|
||||
greyed-out with a reason rather than hiding them outright."""
|
||||
user = require_user_api(request, db)
|
||||
layouts = db.scalars(
|
||||
select(SavedLayout).where(SavedLayout.user_id == user.id).order_by(SavedLayout.name)
|
||||
).all()
|
||||
return {"layouts": [_layout_summary(db, layout, frame) for layout in layouts]}
|
||||
|
||||
|
||||
class LayoutSaveRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
def _snapshot_widget_config(widget: Widget, config) -> dict:
|
||||
fields = LAYOUT_CONFIG_FIELDS.get(widget.widget_type, ())
|
||||
return {field: getattr(config, field) for field in fields}
|
||||
|
||||
|
||||
def _snapshot_sources(db: Session, widget_id: int, model: type) -> list[dict]:
|
||||
rows = db.scalars(select(model).where(model.widget_id == widget_id)).all()
|
||||
return [
|
||||
{
|
||||
"user_id": row.user_id, "calendar_key": row.calendar_key, "calendar_label": row.calendar_label,
|
||||
"included": row.included, "color_index": row.color_index,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/layouts")
|
||||
def api_layout_save(
|
||||
body: LayoutSaveRequest, request: Request,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
"""Snapshots this frame's current widgets (placement + settings,
|
||||
see LAYOUT_CONFIG_FIELDS), their calendar/task sources, and their
|
||||
button-action bindings into a named layout owned by the calling
|
||||
user. Saving again with a name this user already has overwrites that
|
||||
layout's snapshot in place (see models.SavedLayout's docstring)
|
||||
rather than erroring or creating a duplicate."""
|
||||
user = require_user_api(request, db)
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name is required")
|
||||
if len(name) > MAX_LAYOUT_NAME_LEN:
|
||||
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
|
||||
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
|
||||
now = time.time()
|
||||
existing = db.execute(
|
||||
select(SavedLayout).where(SavedLayout.user_id == user.id, SavedLayout.name == name)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
layout = existing
|
||||
layout.cols, layout.rows, layout.updated_at = cols, rows, now
|
||||
db.execute(delete(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id))
|
||||
db.flush()
|
||||
else:
|
||||
layout = SavedLayout(user_id=user.id, name=name, cols=cols, rows=rows, created_at=now, updated_at=now)
|
||||
db.add(layout)
|
||||
db.flush()
|
||||
|
||||
snapshot_id_by_widget_id: dict[int, int] = {}
|
||||
for widget in widgets:
|
||||
config = db.get(WIDGET_CONFIG_MODELS[widget.widget_type], widget.id)
|
||||
snapshot = SavedLayoutWidget(
|
||||
saved_layout_id=layout.id, widget_type=widget.widget_type,
|
||||
x=widget.x, y=widget.y, w=widget.w, h=widget.h, sort_order=widget.sort_order,
|
||||
config=_snapshot_widget_config(widget, config),
|
||||
image=config.image if widget.widget_type == "static" else None,
|
||||
)
|
||||
db.add(snapshot)
|
||||
db.flush()
|
||||
snapshot_id_by_widget_id[widget.id] = snapshot.id
|
||||
|
||||
source_model = SOURCE_MODELS.get(widget.widget_type)
|
||||
if source_model is not None:
|
||||
model, kind = source_model
|
||||
for source in _snapshot_sources(db, widget.id, model):
|
||||
db.add(SavedLayoutSource(saved_layout_widget_id=snapshot.id, kind=kind, **source))
|
||||
|
||||
actions = db.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
|
||||
for action in actions:
|
||||
snapshot_id = snapshot_id_by_widget_id.get(action.widget_id)
|
||||
if snapshot_id is None:
|
||||
continue
|
||||
db.add(SavedLayoutButtonAction(
|
||||
saved_layout_widget_id=snapshot_id, button=action.button, action=action.action,
|
||||
sort_order=action.sort_order,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return _layout_summary(db, layout, frame)
|
||||
|
||||
|
||||
class LayoutRenameRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.patch("/api/layouts/{layout_id}")
|
||||
def api_layout_rename(layout_id: int, body: LayoutRenameRequest, request: Request, db: Session = Depends(get_db)):
|
||||
user = require_user_api(request, db)
|
||||
layout = _user_owned_layout(db, layout_id, user)
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Name is required")
|
||||
if len(name) > MAX_LAYOUT_NAME_LEN:
|
||||
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
|
||||
conflict = db.execute(
|
||||
select(SavedLayout).where(
|
||||
SavedLayout.user_id == user.id, SavedLayout.name == name, SavedLayout.id != layout.id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if conflict is not None:
|
||||
raise HTTPException(400, "You already have a saved layout with that name")
|
||||
layout.name = name
|
||||
layout.updated_at = time.time()
|
||||
db.commit()
|
||||
return _layout_summary(db, layout)
|
||||
|
||||
|
||||
@router.delete("/api/layouts/{layout_id}")
|
||||
def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
user = require_user_api(request, db)
|
||||
layout = _user_owned_layout(db, layout_id, user)
|
||||
db.delete(layout)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
|
||||
def api_layout_apply(
|
||||
layout_id: int, request: Request,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replaces this frame's entire widget arrangement with a saved
|
||||
layout's -- every current widget (and its own config/sources/button
|
||||
actions, all ondelete="CASCADE") is deleted first, same "act
|
||||
unconditionally on the server, confirm on the client" posture as
|
||||
api_widgets.api_widgets_clear. A source whose owning user account
|
||||
(or a whiteboard's user_id) no longer exists is silently dropped
|
||||
rather than left dangling -- config is JSON, not FK-checked, so
|
||||
nothing enforces that at the storage layer."""
|
||||
user = require_user_api(request, db)
|
||||
layout = _user_owned_layout(db, layout_id, user)
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
if (layout.cols, layout.rows) != (cols, rows):
|
||||
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
|
||||
|
||||
snapshots = db.scalars(
|
||||
select(SavedLayoutWidget)
|
||||
.where(SavedLayoutWidget.saved_layout_id == layout.id)
|
||||
.order_by(SavedLayoutWidget.sort_order)
|
||||
).all()
|
||||
|
||||
with frame_locked(db, frame.id):
|
||||
for widget in db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all():
|
||||
db.delete(widget)
|
||||
db.flush()
|
||||
|
||||
new_widget_id_by_snapshot_id: dict[int, int] = {}
|
||||
for snapshot in snapshots:
|
||||
widget = Widget(
|
||||
frame_id=frame.id, widget_type=snapshot.widget_type,
|
||||
x=snapshot.x, y=snapshot.y, w=snapshot.w, h=snapshot.h,
|
||||
sort_order=snapshot.sort_order, created_at=time.time(),
|
||||
)
|
||||
db.add(widget)
|
||||
db.flush()
|
||||
new_widget_id_by_snapshot_id[snapshot.id] = widget.id
|
||||
|
||||
config = WIDGET_CONFIG_MODELS[snapshot.widget_type](widget_id=widget.id)
|
||||
for field, value in snapshot.config.items():
|
||||
if field == "user_id" and value is not None and db.get(User, value) is None:
|
||||
value = None # that WebDAV account no longer exists
|
||||
setattr(config, field, value)
|
||||
if snapshot.widget_type == "static" and snapshot.image is not None:
|
||||
config.image = snapshot.image
|
||||
config.uploaded_at = time.time()
|
||||
db.add(config)
|
||||
|
||||
source_model = SOURCE_MODELS.get(snapshot.widget_type)
|
||||
if source_model is not None:
|
||||
model, kind = source_model
|
||||
sources = db.scalars(
|
||||
select(SavedLayoutSource).where(
|
||||
SavedLayoutSource.saved_layout_widget_id == snapshot.id, SavedLayoutSource.kind == kind
|
||||
)
|
||||
).all()
|
||||
for source in sources:
|
||||
if db.get(User, source.user_id) is None:
|
||||
continue # that account no longer exists
|
||||
db.add(model(
|
||||
widget_id=widget.id, user_id=source.user_id, calendar_key=source.calendar_key,
|
||||
calendar_label=source.calendar_label, included=source.included,
|
||||
color_index=source.color_index,
|
||||
))
|
||||
|
||||
for snapshot in snapshots:
|
||||
actions = db.scalars(
|
||||
select(SavedLayoutButtonAction)
|
||||
.where(SavedLayoutButtonAction.saved_layout_widget_id == snapshot.id)
|
||||
.order_by(SavedLayoutButtonAction.sort_order)
|
||||
).all()
|
||||
for action in actions:
|
||||
db.add(FrameButtonAction(
|
||||
frame_id=frame.id, button=action.button,
|
||||
widget_id=new_widget_id_by_snapshot_id[snapshot.id], action=action.action,
|
||||
sort_order=action.sort_order, created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return {"status": "applied", "widget_count": len(snapshots)}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Layout tab's "Saved layouts" card: save the current widget arrangement
|
||||
// (placement, settings, button assignments -- see routers/api_layouts.py)
|
||||
// under a name, then switch back to it later. Layouts are owned by the
|
||||
// logged-in user, not this frame, so this always hits window.FRAME_BASE_API
|
||||
// (the frame-level base) rather than window.FRAME_API, which the widget
|
||||
// dialog machinery in frame_layout.js temporarily repoints at a specific
|
||||
// widget while its gear-icon dialog is open.
|
||||
|
||||
let savedLayouts = [];
|
||||
let editingLayoutId = null; // inline rename in progress, same pattern as frame_header.js's name pencil-edit
|
||||
|
||||
function renderSavedLayouts() {
|
||||
const list = document.getElementById('saved-layout-list');
|
||||
const emptyHint = document.getElementById('saved-layout-empty-hint');
|
||||
list.innerHTML = '';
|
||||
emptyHint.style.display = savedLayouts.length ? 'none' : '';
|
||||
|
||||
savedLayouts.forEach((layout) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'saved-layout-row';
|
||||
|
||||
if (editingLayoutId === layout.id) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.maxLength = 60;
|
||||
input.value = layout.name;
|
||||
input.className = 'saved-layout-rename-input';
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.className = 'btn-inline';
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.addEventListener('click', () => renameSavedLayout(layout, input.value));
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'btn-inline secondary';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.addEventListener('click', () => { editingLayoutId = null; renderSavedLayouts(); });
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') renameSavedLayout(layout, input.value);
|
||||
if (e.key === 'Escape') { editingLayoutId = null; renderSavedLayouts(); }
|
||||
});
|
||||
|
||||
li.appendChild(input);
|
||||
li.appendChild(saveBtn);
|
||||
li.appendChild(cancelBtn);
|
||||
list.appendChild(li);
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
|
||||
const nameWrap = document.createElement('span');
|
||||
nameWrap.className = 'saved-layout-name';
|
||||
const nameText = document.createElement('span');
|
||||
nameText.textContent = layout.name;
|
||||
nameWrap.appendChild(nameText);
|
||||
if (!layout.compatible) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'saved-layout-badge';
|
||||
badge.textContent = 'different orientation';
|
||||
nameWrap.appendChild(badge);
|
||||
}
|
||||
li.appendChild(nameWrap);
|
||||
|
||||
const controls = document.createElement('span');
|
||||
controls.className = 'saved-layout-controls';
|
||||
|
||||
const applyBtn = document.createElement('button');
|
||||
applyBtn.type = 'button';
|
||||
applyBtn.className = 'btn-inline';
|
||||
applyBtn.textContent = 'Apply';
|
||||
applyBtn.disabled = !layout.compatible;
|
||||
applyBtn.title = layout.compatible
|
||||
? `Replace the current arrangement with "${layout.name}"`
|
||||
: "This layout was saved for a different orientation's grid";
|
||||
applyBtn.addEventListener('click', () => applySavedLayout(layout));
|
||||
controls.appendChild(applyBtn);
|
||||
|
||||
const renameBtn = document.createElement('button');
|
||||
renameBtn.type = 'button';
|
||||
renameBtn.className = 'icon-btn';
|
||||
renameBtn.textContent = '✎'; // pencil
|
||||
renameBtn.title = 'Rename';
|
||||
renameBtn.addEventListener('click', () => { editingLayoutId = layout.id; renderSavedLayouts(); });
|
||||
controls.appendChild(renameBtn);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'icon-btn';
|
||||
deleteBtn.textContent = '×';
|
||||
deleteBtn.title = 'Delete';
|
||||
deleteBtn.addEventListener('click', () => deleteSavedLayout(layout));
|
||||
controls.appendChild(deleteBtn);
|
||||
|
||||
li.appendChild(controls);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSavedLayouts() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`);
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
savedLayouts = (await resp.json()).layouts;
|
||||
renderSavedLayouts();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentLayout() {
|
||||
const input = document.getElementById('saved-layout-name');
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
showStatus(false, 'Give this layout a name first.');
|
||||
return;
|
||||
}
|
||||
const existing = savedLayouts.find((l) => l.name === name);
|
||||
if (existing && !confirm(`You already have a saved layout named "${name}" -- overwrite it with the current arrangement?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
input.value = '';
|
||||
showStatus(true, `Saved layout "${name}".`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadSavedLayouts();
|
||||
}
|
||||
}
|
||||
document.getElementById('save-layout-btn').addEventListener('click', saveCurrentLayout);
|
||||
document.getElementById('saved-layout-name').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') saveCurrentLayout();
|
||||
});
|
||||
|
||||
async function applySavedLayout(layout) {
|
||||
if (!confirm(`Replace the current arrangement with "${layout.name}"? Widgets not in that layout will be removed.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/layouts/${layout.id}/apply`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, `Applied "${layout.name}".`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadWidgets(); // see frame_layout.js -- reloads the placement canvas
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSavedLayout(layout, rawName) {
|
||||
const name = rawName.trim();
|
||||
if (!name || name === layout.name) {
|
||||
editingLayoutId = null;
|
||||
renderSavedLayouts();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/layouts/${layout.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Renamed.');
|
||||
editingLayoutId = null;
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadSavedLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSavedLayout(layout) {
|
||||
if (!confirm(`Delete the saved layout "${layout.name}"? This can't be undone.`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/layouts/${layout.id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Deleted.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadSavedLayouts();
|
||||
}
|
||||
}
|
||||
|
||||
loadSavedLayouts();
|
||||
@@ -280,6 +280,34 @@ input:focus, select:focus {
|
||||
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
|
||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
|
||||
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.saved-layout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.saved-layout-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.saved-layout-name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.saved-layout-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.saved-layout-controls .btn-inline { margin: 0; }
|
||||
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
|
||||
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.button-action-add select { width: auto; margin-top: 0; }
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@
|
||||
<div id="add-widget-buttons" class="checkbox-row" style="gap: 10px; flex-wrap: wrap;"></div>
|
||||
<p class="sub" id="add-widget-hint" style="margin-top: 8px;"></p>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Saved layouts</h2>
|
||||
<p class="sub">Save this whole arrangement -- widgets, their settings, and button
|
||||
assignments -- under a name, then switch back to it any time. Saved layouts are
|
||||
yours across every frame you control with a matching orientation, not just this one.</p>
|
||||
<div class="saved-layout-add">
|
||||
<input type="text" id="saved-layout-name" placeholder="Layout name" maxlength="60">
|
||||
<button type="button" id="save-layout-btn" class="secondary">Save current as...</button>
|
||||
</div>
|
||||
<ul class="saved-layout-list" id="saved-layout-list" style="margin-top: 14px;"></ul>
|
||||
<p class="sub" id="saved-layout-empty-hint" style="margin-top: 10px;">No saved layouts yet.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,4 +75,5 @@
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
<script src="/static/saved_layouts.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -188,20 +188,22 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
||||
migrate."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs are migration 20/21
|
||||
# tables (also post-16, like the rest of this list) -- dropped
|
||||
# here too so a real version-15 database is what's actually
|
||||
# being simulated, not "version 15 plus two tables that
|
||||
# wouldn't exist yet". Harmless to omit as long as no migration
|
||||
# after the one that creates a table also ALTERs it (that's
|
||||
# what let static_widget_configs go unlisted safely so far --
|
||||
# Base.metadata.create_all is idempotent against an
|
||||
# already-present table with no later ALTER to collide with),
|
||||
# but text_widget_configs' migration 22 ALTER makes the gap a
|
||||
# real "table already exists"/"duplicate column" collision
|
||||
# instead of a silent no-op.
|
||||
# tables, and saved_layouts/saved_layout_widgets/saved_layout_
|
||||
# sources/saved_layout_button_actions are migration 23's (all
|
||||
# post-16, like the rest of this list) -- dropped here too so a
|
||||
# real version-15 database is what's actually being simulated,
|
||||
# not "version 15 plus tables that wouldn't exist yet". Harmless
|
||||
# to omit as long as no migration after the one that creates a
|
||||
# table also ALTERs it (that's what let static_widget_configs go
|
||||
# unlisted safely so far -- Base.metadata.create_all is
|
||||
# idempotent against an already-present table with no later
|
||||
# ALTER to collide with), but text_widget_configs' migration 22
|
||||
# ALTER makes the gap a real "table already exists"/"duplicate
|
||||
# column" collision instead of a silent no-op.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"text_widget_configs", "widgets"):
|
||||
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
||||
"saved_layout_widgets", "saved_layouts", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
@@ -268,18 +270,22 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
|
||||
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
||||
calendar_key columns either."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs dropped too -- see
|
||||
# the comment on the identical setup in
|
||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||
# too -- see the comment on the identical setup in
|
||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
|
||||
# 20/21's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 22 and neither
|
||||
# table would really exist yet at a genuine pre-migration-17
|
||||
# 20/21/23's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 23 and none of
|
||||
# these tables would really exist yet at a genuine pre-migration-17
|
||||
# schema_version).
|
||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||
conn.execute(text("DROP TABLE frame_task_lists"))
|
||||
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||
conn.execute(text("DROP TABLE static_widget_configs"))
|
||||
conn.execute(text("DROP TABLE text_widget_configs"))
|
||||
conn.execute(text("DROP TABLE saved_layout_button_actions"))
|
||||
conn.execute(text("DROP TABLE saved_layout_sources"))
|
||||
conn.execute(text("DROP TABLE saved_layout_widgets"))
|
||||
conn.execute(text("DROP TABLE saved_layouts"))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
@@ -374,12 +380,13 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
|
||||
the widget backfill, not as a numbered migration racing ahead of
|
||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs dropped too -- see
|
||||
# the comment on the identical setup in
|
||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||
# too -- see the comment on the identical setup in
|
||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"text_widget_configs", "widgets"):
|
||||
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
||||
"saved_layout_widgets", "saved_layouts", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""routers/api_layouts.py -- named, user-owned saved layouts. Save/apply
|
||||
snapshot/restore a frame's whole widget arrangement (placement, per-type
|
||||
settings, calendar/task sources, button-action bindings); list/rename/
|
||||
delete operate purely on "is this your own saved layout", independent of
|
||||
any frame -- see models.SavedLayout's docstring for why layouts are
|
||||
user-owned rather than frame-owned.
|
||||
|
||||
Frame #1's auto-migrated widget (see migration.py's backfill) is a
|
||||
single full-panel (0, 0, 8, 5) photos widget -- same starting point
|
||||
test_widget_placement.py's tests use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
SavedLayout,
|
||||
SavedLayoutButtonAction,
|
||||
SavedLayoutSource,
|
||||
SavedLayoutWidget,
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
)
|
||||
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
|
||||
def _widget_id(db_session, widget_type="photos", frame_id=1) -> int:
|
||||
return db_session.query(Widget).filter_by(frame_id=frame_id, widget_type=widget_type).one().id
|
||||
|
||||
|
||||
def _add_calendar_widget(db_session, frame_id=1, x=0, y=0, w=3, h=2, sort_order=1) -> Widget:
|
||||
widget = Widget(frame_id=frame_id, widget_type="calendar", x=x, y=y, w=w, h=h,
|
||||
sort_order=sort_order, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _add_tasks_widget(db_session, frame_id=1, x=3, y=0, w=2, h=2, sort_order=2) -> Widget:
|
||||
widget = Widget(frame_id=frame_id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
||||
sort_order=sort_order, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(TaskWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _setup_alice(client) -> None:
|
||||
resp = client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
assert resp.status_code == 303, resp.text
|
||||
|
||||
|
||||
# --- save ------------------------------------------------------------------
|
||||
|
||||
def test_save_captures_placement_and_photo_settings_but_not_queue_state(client, db_session):
|
||||
_setup_alice(client)
|
||||
photos_id = _widget_id(db_session)
|
||||
with db_session.no_autoflush:
|
||||
pcfg = db_session.get(PhotoWidgetConfig, photos_id)
|
||||
pcfg.album_id = "album-1"
|
||||
pcfg.order = "shuffle"
|
||||
pcfg.queue_target_len = 30
|
||||
pcfg.current_asset_id = "some-asset"
|
||||
pcfg.queue = ["a", "b", "c"]
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "My Layout"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "My Layout"
|
||||
assert body["cols"] == 8 and body["rows"] == 5
|
||||
assert body["widget_count"] == 1
|
||||
|
||||
layout = db_session.query(SavedLayout).filter_by(user_id=1, name="My Layout").one()
|
||||
snap = db_session.query(SavedLayoutWidget).filter_by(saved_layout_id=layout.id).one()
|
||||
assert (snap.widget_type, snap.x, snap.y, snap.w, snap.h) == ("photos", 0, 0, 8, 5)
|
||||
assert snap.config == {"album_id": "album-1", "order": "shuffle", "display_mode": "crop_faces",
|
||||
"queue_target_len": 30}
|
||||
|
||||
|
||||
def test_save_captures_calendar_sources_and_button_actions(client, db_session):
|
||||
_setup_alice(client)
|
||||
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
||||
db_session.commit()
|
||||
cal = _add_calendar_widget(db_session)
|
||||
db_session.add(FrameCalendar(widget_id=cal.id, user_id=1, calendar_key="ics", calendar_label="Alice",
|
||||
included=True, color_index=2))
|
||||
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=cal.id, action="advance", sort_order=0))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Calendar Layout"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
layout = db_session.query(SavedLayout).filter_by(user_id=1, name="Calendar Layout").one()
|
||||
snap = db_session.query(SavedLayoutWidget).filter_by(saved_layout_id=layout.id).one()
|
||||
source = db_session.query(SavedLayoutSource).filter_by(saved_layout_widget_id=snap.id).one()
|
||||
assert (source.kind, source.user_id, source.calendar_key, source.color_index) == ("calendar", 1, "ics", 2)
|
||||
action = db_session.query(SavedLayoutButtonAction).filter_by(saved_layout_widget_id=snap.id).one()
|
||||
assert (action.button, action.action) == ("next", "advance")
|
||||
|
||||
|
||||
def test_save_with_existing_name_overwrites_in_place(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp1 = client.post("/api/frames/1/layouts", json={"name": "Layout A"}, headers=csrf_headers(client))
|
||||
layout_id = resp1.json()["id"]
|
||||
|
||||
photos_id = _widget_id(db_session)
|
||||
client.patch(f"/api/frames/1/widgets/{photos_id}", json={"x": 0, "y": 0, "w": 4, "h": 5},
|
||||
headers=csrf_headers(client))
|
||||
resp2 = client.post("/api/frames/1/layouts", json={"name": "Layout A"}, headers=csrf_headers(client))
|
||||
assert resp2.status_code == 200, resp2.text
|
||||
assert resp2.json()["id"] == layout_id
|
||||
|
||||
assert db_session.query(SavedLayout).filter_by(user_id=1, name="Layout A").count() == 1
|
||||
snap = db_session.query(SavedLayoutWidget).filter_by(saved_layout_id=layout_id).one()
|
||||
assert (snap.w, snap.h) == (4, 5)
|
||||
|
||||
|
||||
def test_save_rejects_blank_name(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": " "}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_save_linked_but_not_controlling_user_409s(client, db_session):
|
||||
_setup_alice(client)
|
||||
bob = make_user(db_session, "bob")
|
||||
link_user(db_session, bob, db_session.get(Frame, 1))
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Bob's Layout"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["error"] == "not_controller"
|
||||
|
||||
|
||||
# --- list --------------------------------------------------------------
|
||||
|
||||
def test_list_is_scoped_to_the_calling_user(client, db_session):
|
||||
_setup_alice(client)
|
||||
bob = make_user(db_session, "bob")
|
||||
link_user(db_session, bob, db_session.get(Frame, 1))
|
||||
client.post("/api/frames/1/layouts", json={"name": "Alice's Layout"}, headers=csrf_headers(client))
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.get("/api/frames/1/layouts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["layouts"] == []
|
||||
|
||||
|
||||
def test_list_flags_layouts_incompatible_with_this_frames_grid(client, db_session):
|
||||
_setup_alice(client)
|
||||
client.post("/api/frames/1/layouts", json={"name": "Landscape Layout"}, headers=csrf_headers(client))
|
||||
|
||||
other_frame = Frame(name="Portrait frame", manage_token="tok-2", device_id="dev-2", device_token="dtok-2",
|
||||
orientation="portrait")
|
||||
db_session.add(other_frame)
|
||||
db_session.commit()
|
||||
from app import grid
|
||||
from app.models import Widget as W
|
||||
cols, rows = grid.grid_dims("portrait")
|
||||
db_session.add(W(frame_id=other_frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
|
||||
sort_order=0, created_at=time.time()))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get(f"/api/frames/{other_frame.id}/layouts")
|
||||
assert resp.status_code == 200
|
||||
layouts = resp.json()["layouts"]
|
||||
assert len(layouts) == 1
|
||||
assert layouts[0]["name"] == "Landscape Layout"
|
||||
assert layouts[0]["compatible"] is False
|
||||
|
||||
resp2 = client.get("/api/frames/1/layouts")
|
||||
assert resp2.json()["layouts"][0]["compatible"] is True
|
||||
|
||||
|
||||
# --- rename / delete -----------------------------------------------------
|
||||
|
||||
def test_rename_updates_the_name(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Old Name"}, headers=csrf_headers(client))
|
||||
layout_id = resp.json()["id"]
|
||||
|
||||
rename_resp = client.patch(f"/api/layouts/{layout_id}", json={"name": "New Name"}, headers=csrf_headers(client))
|
||||
assert rename_resp.status_code == 200, rename_resp.text
|
||||
assert rename_resp.json()["name"] == "New Name"
|
||||
assert db_session.get(SavedLayout, layout_id).name == "New Name"
|
||||
|
||||
|
||||
def test_rename_rejects_conflicting_name(client, db_session):
|
||||
_setup_alice(client)
|
||||
client.post("/api/frames/1/layouts", json={"name": "Layout A"}, headers=csrf_headers(client))
|
||||
resp_b = client.post("/api/frames/1/layouts", json={"name": "Layout B"}, headers=csrf_headers(client))
|
||||
layout_b_id = resp_b.json()["id"]
|
||||
|
||||
resp = client.patch(f"/api/layouts/{layout_b_id}", json={"name": "Layout A"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_rename_someone_elses_layout_404s(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Alice's Layout"}, headers=csrf_headers(client))
|
||||
layout_id = resp.json()["id"]
|
||||
bob = make_user(db_session, "bob")
|
||||
link_user(db_session, bob, db_session.get(Frame, 1))
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.patch(f"/api/layouts/{layout_id}", json={"name": "Hijacked"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
assert db_session.get(SavedLayout, layout_id).name == "Alice's Layout"
|
||||
|
||||
|
||||
def test_delete_removes_the_layout_and_cascades(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Doomed"}, headers=csrf_headers(client))
|
||||
layout_id = resp.json()["id"]
|
||||
|
||||
del_resp = client.delete(f"/api/layouts/{layout_id}", headers=csrf_headers(client))
|
||||
assert del_resp.status_code == 200
|
||||
assert db_session.get(SavedLayout, layout_id) is None
|
||||
assert db_session.query(SavedLayoutWidget).filter_by(saved_layout_id=layout_id).count() == 0
|
||||
|
||||
|
||||
def test_delete_someone_elses_layout_404s(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Alice's Layout"}, headers=csrf_headers(client))
|
||||
layout_id = resp.json()["id"]
|
||||
bob = make_user(db_session, "bob")
|
||||
link_user(db_session, bob, db_session.get(Frame, 1))
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.delete(f"/api/layouts/{layout_id}", headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
assert db_session.get(SavedLayout, layout_id) is not None
|
||||
|
||||
|
||||
# --- apply ---------------------------------------------------------------
|
||||
|
||||
def test_apply_replaces_widgets_and_restores_sources_and_button_actions(client, db_session):
|
||||
_setup_alice(client)
|
||||
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
||||
db_session.commit()
|
||||
cal = _add_calendar_widget(db_session, x=0, y=0, w=3, h=2, sort_order=0)
|
||||
tasks = _add_tasks_widget(db_session, x=3, y=0, w=2, h=2, sort_order=1)
|
||||
db_session.add(FrameCalendar(widget_id=cal.id, user_id=1, calendar_key="ics", calendar_label="Alice",
|
||||
included=True))
|
||||
db_session.add(FrameTaskList(widget_id=tasks.id, user_id=1, calendar_key="caldav:/tasks/", included=True))
|
||||
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=cal.id, action="advance", sort_order=0))
|
||||
db_session.add(FrameButtonAction(frame_id=1, button="back", widget_id=cal.id, action="back", sort_order=0))
|
||||
db_session.commit()
|
||||
|
||||
save_resp = client.post("/api/frames/1/layouts", json={"name": "Rich Layout"}, headers=csrf_headers(client))
|
||||
layout_id = save_resp.json()["id"]
|
||||
|
||||
# Blow away the current arrangement so apply has something real to restore.
|
||||
client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
|
||||
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0
|
||||
|
||||
apply_resp = client.post(f"/api/frames/1/layouts/{layout_id}/apply", headers=csrf_headers(client))
|
||||
assert apply_resp.status_code == 200, apply_resp.text
|
||||
assert apply_resp.json()["widget_count"] == 2
|
||||
|
||||
widgets = db_session.query(Widget).filter_by(frame_id=1).order_by(Widget.sort_order).all()
|
||||
assert [w.widget_type for w in widgets] == ["calendar", "tasks"]
|
||||
new_cal, new_tasks = widgets
|
||||
|
||||
cal_source = db_session.query(FrameCalendar).filter_by(widget_id=new_cal.id).one()
|
||||
assert (cal_source.user_id, cal_source.calendar_key) == (1, "ics")
|
||||
task_source = db_session.query(FrameTaskList).filter_by(widget_id=new_tasks.id).one()
|
||||
assert (task_source.user_id, task_source.calendar_key) == (1, "caldav:/tasks/")
|
||||
|
||||
actions = db_session.query(FrameButtonAction).filter_by(frame_id=1).all()
|
||||
assert len(actions) == 2
|
||||
assert all(a.widget_id == new_cal.id for a in actions)
|
||||
assert {a.button for a in actions} == {"next", "back"}
|
||||
|
||||
|
||||
def test_apply_rejects_mismatched_grid(client, db_session):
|
||||
_setup_alice(client)
|
||||
save_resp = client.post("/api/frames/1/layouts", json={"name": "Landscape Layout"},
|
||||
headers=csrf_headers(client))
|
||||
layout_id = save_resp.json()["id"]
|
||||
|
||||
other_frame = Frame(name="Portrait frame", manage_token="tok-2", device_id="dev-2", device_token="dtok-2",
|
||||
orientation="portrait", controlled_by_user_id=1)
|
||||
db_session.add(other_frame)
|
||||
db_session.commit()
|
||||
from app import grid
|
||||
from app.models import Widget as W
|
||||
cols, rows = grid.grid_dims("portrait")
|
||||
db_session.add(W(frame_id=other_frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
|
||||
sort_order=0, created_at=time.time()))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post(f"/api/frames/{other_frame.id}/layouts/{layout_id}/apply", headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "different frame size" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_apply_drops_a_source_whose_owning_user_no_longer_exists(client, db_session):
|
||||
_setup_alice(client)
|
||||
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
||||
db_session.commit()
|
||||
cal = _add_calendar_widget(db_session)
|
||||
bob = make_user(db_session, "bob")
|
||||
link_user(db_session, bob, db_session.get(Frame, 1))
|
||||
bob_id = bob.id
|
||||
db_session.add(FrameCalendar(widget_id=cal.id, user_id=bob_id, calendar_key="ics", calendar_label="Bob",
|
||||
included=True))
|
||||
db_session.commit()
|
||||
|
||||
save_resp = client.post("/api/frames/1/layouts", json={"name": "Shared Calendar"}, headers=csrf_headers(client))
|
||||
layout_id = save_resp.json()["id"]
|
||||
|
||||
db_session.query(User).filter_by(id=bob_id).delete()
|
||||
db_session.commit()
|
||||
|
||||
client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
|
||||
apply_resp = client.post(f"/api/frames/1/layouts/{layout_id}/apply", headers=csrf_headers(client))
|
||||
assert apply_resp.status_code == 200, apply_resp.text
|
||||
|
||||
new_cal = db_session.query(Widget).filter_by(frame_id=1, widget_type="calendar").one()
|
||||
assert db_session.query(FrameCalendar).filter_by(widget_id=new_cal.id).count() == 0
|
||||
|
||||
|
||||
def test_apply_someone_elses_layout_404s(client, db_session):
|
||||
_setup_alice(client)
|
||||
resp = client.post("/api/frames/1/layouts", json={"name": "Alice's Layout"}, headers=csrf_headers(client))
|
||||
layout_id = resp.json()["id"]
|
||||
bob = make_user(db_session, "bob")
|
||||
link_user(db_session, bob, db_session.get(Frame, 1))
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.controlled_by_user_id = bob.id
|
||||
db_session.commit()
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/layouts/{layout_id}/apply", headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user