Saved layouts feature
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s

This commit is contained in:
Thomas Faour
2026-07-25 18:18:48 +00:00
parent edbd90745b
commit c1c657c0a9
10 changed files with 1153 additions and 21 deletions
+41
View File
@@ -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
View File
@@ -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)
+67
View File
@@ -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),
]
+103
View File
@@ -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
+320
View File
@@ -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)}
+196
View File
@@ -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();
+28
View File
@@ -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; }
+14
View File
@@ -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 %}
+27 -20
View File
@@ -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(
+353
View File
@@ -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