Files
espresso_frame/server/app/routers/api_layouts.py
T
tfaour fcf3aec4c0
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s
Move button actions to per-widget config, add hold-for-global-action
Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
2026-07-27 22:09:33 +00:00

334 lines
14 KiB
Python

"""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"),
"battery": ("mode",),
}
# 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"}
def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
"""Replaces frame's entire widget arrangement with layout's snapshot
-- 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. Shared by api_layout_apply
(explicit user action) and global_actions.cycle_layout (a hold-
triggered global action, see app/global_actions.py) -- caller is
responsible for checking the grid-size match first. Returns the
number of widgets applied."""
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 len(snapshots)
@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 -- see apply_layout_to_frame above for what that actually
does."""
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")
widget_count = apply_layout_to_frame(db, frame, layout)
return {"status": "applied", "widget_count": widget_count}