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.
334 lines
14 KiB
Python
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}
|