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
|
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
|
||||||
for the always-present header/status-bar JS.
|
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)
|
## Known gaps (Phase 6, not yet done)
|
||||||
|
|
||||||
The original 8-phase rollout plan's last phase is still open:
|
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)
|
device.py the firmware-facing /frame/* protocol (paths frozen)
|
||||||
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
||||||
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
|
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
|
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
|
||||||
pages.py setup/login/claim/settings/admin
|
pages.py setup/login/claim/settings/admin
|
||||||
manage.py the limited manage-QR surface (/m/, /api/m/)
|
manage.py the limited manage-QR surface (/m/, /api/m/)
|
||||||
@@ -31,7 +33,7 @@ from .auth import (
|
|||||||
)
|
)
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal
|
||||||
from .models import Frame
|
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
|
from .routers.common import shell_context
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(device.router)
|
||||||
app.include_router(api_frames.router)
|
app.include_router(api_frames.router)
|
||||||
app.include_router(api_widgets.router)
|
app.include_router(api_widgets.router)
|
||||||
|
app.include_router(api_layouts.router)
|
||||||
app.include_router(frame_pages.router)
|
app.include_router(frame_pages.router)
|
||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
app.include_router(manage.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'"))
|
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 = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -581,6 +647,7 @@ MIGRATIONS = [
|
|||||||
(20, _migration_20),
|
(20, _migration_20),
|
||||||
(21, _migration_21),
|
(21, _migration_21),
|
||||||
(22, _migration_22),
|
(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):
|
class PendingClaim(Base):
|
||||||
"""A claim submitted before the frame's first check-in (the user beat
|
"""A claim submitted before the frame's first check-in (the user beat
|
||||||
the device to the server after provisioning). Attached automatically
|
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 { 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 { padding: 3px 6px; font-size: 13px; }
|
||||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
.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 { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||||
.button-action-add select { width: auto; margin-top: 0; }
|
.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>
|
<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>
|
<p class="sub" id="add-widget-hint" style="margin-top: 8px;"></p>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,4 +75,5 @@
|
|||||||
<script src="/static/widget_dialog_static.js"></script>
|
<script src="/static/widget_dialog_static.js"></script>
|
||||||
<script src="/static/widget_dialog_text.js"></script>
|
<script src="/static/widget_dialog_text.js"></script>
|
||||||
<script src="/static/frame_layout.js"></script>
|
<script src="/static/frame_layout.js"></script>
|
||||||
|
<script src="/static/saved_layouts.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -188,20 +188,22 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
|||||||
migrate."""
|
migrate."""
|
||||||
with db_module.engine.begin() as conn:
|
with db_module.engine.begin() as conn:
|
||||||
# static_widget_configs/text_widget_configs are migration 20/21
|
# static_widget_configs/text_widget_configs are migration 20/21
|
||||||
# tables (also post-16, like the rest of this list) -- dropped
|
# tables, and saved_layouts/saved_layout_widgets/saved_layout_
|
||||||
# here too so a real version-15 database is what's actually
|
# sources/saved_layout_button_actions are migration 23's (all
|
||||||
# being simulated, not "version 15 plus two tables that
|
# post-16, like the rest of this list) -- dropped here too so a
|
||||||
# wouldn't exist yet". Harmless to omit as long as no migration
|
# real version-15 database is what's actually being simulated,
|
||||||
# after the one that creates a table also ALTERs it (that's
|
# not "version 15 plus tables that wouldn't exist yet". Harmless
|
||||||
# what let static_widget_configs go unlisted safely so far --
|
# to omit as long as no migration after the one that creates a
|
||||||
# Base.metadata.create_all is idempotent against an
|
# table also ALTERs it (that's what let static_widget_configs go
|
||||||
# already-present table with no later ALTER to collide with),
|
# unlisted safely so far -- Base.metadata.create_all is
|
||||||
# but text_widget_configs' migration 22 ALTER makes the gap a
|
# idempotent against an already-present table with no later
|
||||||
# real "table already exists"/"duplicate column" collision
|
# ALTER to collide with), but text_widget_configs' migration 22
|
||||||
# instead of a silent no-op.
|
# 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",
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
"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(f"DROP TABLE {table}"))
|
||||||
conn.execute(text("DROP TABLE frame_calendars"))
|
conn.execute(text("DROP TABLE frame_calendars"))
|
||||||
conn.execute(text(
|
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/
|
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
||||||
calendar_key columns either."""
|
calendar_key columns either."""
|
||||||
with db_module.engine.begin() as conn:
|
with db_module.engine.begin() as conn:
|
||||||
# static_widget_configs/text_widget_configs dropped too -- see
|
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||||
# the comment on the identical setup in
|
# too -- see the comment on the identical setup in
|
||||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
|
# 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
|
# 20/21/23's raw CREATE TABLE collides with an already-present table
|
||||||
# otherwise, since this test replays 17 through 22 and neither
|
# otherwise, since this test replays 17 through 23 and none of
|
||||||
# table would really exist yet at a genuine pre-migration-17
|
# these tables would really exist yet at a genuine pre-migration-17
|
||||||
# schema_version).
|
# schema_version).
|
||||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||||
conn.execute(text("DROP TABLE frame_task_lists"))
|
conn.execute(text("DROP TABLE frame_task_lists"))
|
||||||
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||||
conn.execute(text("DROP TABLE static_widget_configs"))
|
conn.execute(text("DROP TABLE static_widget_configs"))
|
||||||
conn.execute(text("DROP TABLE text_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(
|
conn.execute(text(
|
||||||
"CREATE TABLE calendar_widget_configs ("
|
"CREATE TABLE calendar_widget_configs ("
|
||||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
"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
|
the widget backfill, not as a numbered migration racing ahead of
|
||||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||||
with db_module.engine.begin() as conn:
|
with db_module.engine.begin() as conn:
|
||||||
# static_widget_configs/text_widget_configs dropped too -- see
|
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||||
# the comment on the identical setup in
|
# too -- see the comment on the identical setup in
|
||||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
|
# 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",
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
"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(f"DROP TABLE {table}"))
|
||||||
conn.execute(text("DROP TABLE frame_calendars"))
|
conn.execute(text("DROP TABLE frame_calendars"))
|
||||||
conn.execute(text(
|
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