Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s

Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
This commit is contained in:
2026-07-24 12:48:56 -04:00
parent 99069ba5fe
commit 5d4bb53b8a
11 changed files with 780 additions and 5 deletions
+15
View File
@@ -65,6 +65,21 @@ def overlaps(a: Rect, b: Rect) -> bool:
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
def find_open_rect(orientation: str, existing: list[Rect], w: int, h: int) -> Rect | None:
"""First w x h rect that's in-bounds and doesn't overlap any of
`existing`, scanning row-major (top-left first) -- used when creating
a widget without an explicit placement (see routers/api_widgets.py),
so adding one from a type picker doesn't require the caller to find
empty space itself first. None if no such rect fits anywhere."""
cols, rows = grid_dims(orientation)
for y in range(rows - h + 1):
for x in range(cols - w + 1):
candidate = (x, y, w, h)
if not any(overlaps(candidate, other) for other in existing):
return candidate
return None
def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]:
"""Grid rect -> pixel rect in logical (pre-rotation) canvas space --
against image_pipeline.logical_render_size(orientation)'s own
+4 -2
View File
@@ -4,7 +4,8 @@ for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/:
device.py the firmware-facing /frame/* protocol (paths frozen)
api_frames.py the web UI's JSON API, /api/frames/{id}/...
frame_pages.py the per-frame Photos/Configuration/Stats pages
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
Storage is SQLite via models.py/db.py; migration.py imports a
@@ -30,7 +31,7 @@ from .auth import (
)
from .db import SessionLocal
from .models import Frame
from .routers import api_frames, device, frame_pages, manage, pages
from .routers import api_frames, api_widgets, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
@@ -45,6 +46,7 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
app.include_router(api_frames.router)
app.include_router(api_widgets.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
+46 -3
View File
@@ -25,7 +25,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client
from .. import calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -36,7 +36,15 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
from ..models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
@@ -67,6 +75,32 @@ MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
cols x rows (see grid.grid_dims) -- landscape and portrait use a
transposed grid (8x5 vs 5x8), so an existing placement is often
literally out of bounds on the new grid, not just visually wrong.
There's no sensible coordinate remap between two differently-shaped
grids, so instead: keep whichever widget was first by placement
order, resized to fill the new full panel, and delete the rest --
cascading to their own config rows and any FrameButtonAction
bindings via ondelete="CASCADE" (see models.py). The frontend is
expected to confirm this with the user before submitting an
orientation change (see frame_config.js) -- this always executes
unconditionally once called, same posture as every other
confirm-on-the-client / act-unconditionally-on-the-server action in
this codebase."""
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
).all()
if not widgets:
return
keep, *rest = widgets
for widget in rest:
db.delete(widget)
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
@@ -124,6 +158,12 @@ def api_config_save(
harmless no-ops if an old cached page still POSTs them -- FastAPI
silently ignores form fields with no matching parameter.
An actual orientation *change* resets the frame's widget layout (see
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid.
Until the widget-placement UI (a later phase) lets a frame have more
than one widget of a type, "the photo widget" / "the calendar
widget" below unambiguously means the frame's single auto-migrated
@@ -138,7 +178,10 @@ def api_config_save(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
if new_orientation != cfg.orientation:
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
cfg.orientation = new_orientation
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
+142
View File
@@ -0,0 +1,142 @@
"""CRUD + grid placement for a frame's widgets (see models.Widget) --
backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
Every mutation re-validates bounds/minimum footprint/no-overlap
server-side regardless of what the client already checked -- the
client's own checks are UX, not the source of truth (this project's
usual posture, e.g. api_frames.py's own field clamps)."""
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import 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, WIDGET_CONFIG_MODELS, Widget
from ..widgets import WIDGET_TYPES
router = APIRouter()
def _widget_dict(w: Widget) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
@router.get("/api/frames/{frame_id}/widgets")
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
user = require_user_api(request, db)
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
return {
"orientation": frame.orientation,
"grid": {"cols": cols, "rows": rows},
"widget_types": list(WIDGET_TYPES.keys()),
"min_footprint": grid.MIN_FOOTPRINT,
"widgets": [_widget_dict(w) for w in widgets],
"control": {
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id,
},
}
def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]:
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id]
def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect,
exclude_widget_id: int | None = None) -> None:
if not grid.in_bounds(frame.orientation, rect):
raise HTTPException(400, "Placement is out of bounds for this frame's grid")
if not grid.meets_minimum(widget_type, rect):
min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1))
raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells")
for other_rect in _other_rects(db, frame.id, exclude_widget_id):
if grid.overlaps(rect, other_rect):
raise HTTPException(400, "Overlaps another widget")
class WidgetCreateRequest(BaseModel):
widget_type: str
x: int | None = None
y: int | None = None
w: int | None = None
h: int | None = None
@router.post("/api/frames/{frame_id}/widgets")
def api_widget_create(
body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
if body.widget_type not in WIDGET_TYPES:
raise HTTPException(400, f"Unknown widget type: {body.widget_type}")
min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1))
w, h = body.w or min_w, body.h or min_h
if body.x is None or body.y is None:
rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h)
if rect is None:
raise HTTPException(400, "No open space left for a widget this size")
else:
rect = (body.x, body.y, w, h)
_validate_placement(db, frame, body.widget_type, rect)
with frame_locked(db, frame.id):
max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id))
x, y, w, h = rect
widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h,
sort_order=(max_sort or 0) + 1, created_at=time.time())
db.add(widget)
db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
db.commit()
return _widget_dict(widget)
class WidgetPlacementRequest(BaseModel):
x: int
y: int
w: int
h: int
@router.patch("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_move(
widget_id: int, body: WidgetPlacementRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
rect = (body.x, body.y, body.w, body.h)
_validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id)
with frame_locked(db, frame.id):
widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""Cascades to the widget's own config row and any FrameButtonAction
bindings that pointed at it (both ondelete="CASCADE" FKs, see
models.py) -- nothing left pointing at a widget id that no longer
exists."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
with frame_locked(db, frame.id):
db.delete(widget)
db.commit()
return {"status": "deleted"}
+5
View File
@@ -60,6 +60,11 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
)
@router.get("/frames/{frame_id}/layout", response_class=HTMLResponse)
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any frame:
the single ICS subscription (if set) plus every CalDAV calendar last
+21
View File
@@ -27,10 +27,31 @@ async function saveConfig() {
}
}
// Orientation swaps the widget grid's long/short axis (see
// grid.grid_dims), so an existing widget layout is usually left with
// out-of-bounds coordinates on the new grid -- the server resets it to
// one full-panel widget when this actually changes (see
// api_frames.py's api_config_save). Warn before that happens rather
// than silently losing whatever layout was on the Layout tab.
let lastSavedOrientation = document.getElementById('orientation').value;
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const newOrientation = document.getElementById('orientation').value;
if (newOrientation !== lastSavedOrientation) {
const proceed = confirm(
"Changing orientation resets this frame's widget layout to a single " +
'full-panel widget -- any other widgets placed on the Layout tab will ' +
'be removed. Continue?'
);
if (!proceed) {
document.getElementById('orientation').value = lastSavedOrientation;
return;
}
}
try {
await saveConfig();
lastSavedOrientation = newOrientation;
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
+209
View File
@@ -0,0 +1,209 @@
// Layout tab: drag/resize placement canvas for arranging widgets on the
// panel, like placing widgets on an Android home screen. Pointer events
// (not native HTML5 drag-and-drop, which has known touch
// inconsistencies) drive move/resize; every mutation is re-validated
// server-side (see routers/api_widgets.py) regardless of what this file
// already checked, so after any move/resize/add/remove this just
// reloads the canvas from the server's actual state rather than trusting
// an optimistic update -- simplest way to guarantee the canvas never
// drifts from what a rejected request left in place.
let gridState = null; // last-loaded GET .../widgets response
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadWidgets();
} catch (e) {
showStatus(false, e.message);
}
}
document.getElementById('take-control').addEventListener('click', takeControl);
function cellSizePx() {
const rect = document.getElementById('widget-canvas').getBoundingClientRect();
return { cellW: rect.width / gridState.grid.cols, cellH: rect.height / gridState.grid.rows };
}
function positionBox(box, rect) {
const cols = gridState.grid.cols, rows = gridState.grid.rows;
box.style.left = (rect.x / cols * 100) + '%';
box.style.top = (rect.y / rows * 100) + '%';
box.style.width = (rect.w / cols * 100) + '%';
box.style.height = (rect.h / rows * 100) + '%';
}
function startDrag(e, widget, box, isResize) {
e.preventDefault();
box.setPointerCapture(e.pointerId);
const { cellW, cellH } = cellSizePx();
const startX = e.clientX, startY = e.clientY;
const orig = { x: widget.x, y: widget.y, w: widget.w, h: widget.h };
const cols = gridState.grid.cols, rows = gridState.grid.rows;
const minFootprint = gridState.min_footprint[widget.widget_type] || [1, 1];
let pending = null;
box.classList.add('dragging');
function onMove(ev) {
const dxCells = Math.round((ev.clientX - startX) / cellW);
const dyCells = Math.round((ev.clientY - startY) / cellH);
const next = { ...orig };
if (isResize) {
next.w = Math.max(minFootprint[0], Math.min(cols - orig.x, orig.w + dxCells));
next.h = Math.max(minFootprint[1], Math.min(rows - orig.y, orig.h + dyCells));
} else {
next.x = Math.max(0, Math.min(cols - orig.w, orig.x + dxCells));
next.y = Math.max(0, Math.min(rows - orig.h, orig.y + dyCells));
}
pending = next;
positionBox(box, next);
}
function onUp() {
box.removeEventListener('pointermove', onMove);
box.removeEventListener('pointerup', onUp);
box.classList.remove('dragging');
if (pending && (pending.x !== orig.x || pending.y !== orig.y || pending.w !== orig.w || pending.h !== orig.h)) {
moveWidget(widget.id, pending);
}
}
box.addEventListener('pointermove', onMove);
box.addEventListener('pointerup', onUp);
}
async function moveWidget(id, rect) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rect),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
} finally {
// Reload either way: reverts the box to its real position if the
// move was rejected (e.g. it would've overlapped another widget),
// confirms it otherwise. Simpler and more robust than trying to
// separately handle "revert on failure" vs. "confirm on success".
loadWidgets();
}
}
async function removeWidget(id) {
if (!confirm('Remove this widget? Its own settings will be lost.')) return;
try {
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Removed.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
async function addWidget(widgetType) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_type: widgetType }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, `${WIDGET_LABELS[widgetType] || widgetType} widget added.`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
function renderCanvas() {
const canvas = document.getElementById('widget-canvas');
const wrap = document.getElementById('widget-canvas-wrap');
wrap.style.setProperty('--grid-cols', gridState.grid.cols);
wrap.style.setProperty('--grid-rows', gridState.grid.rows);
canvas.innerHTML = '';
document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : '';
for (const widget of gridState.widgets) {
const box = document.createElement('div');
box.className = 'widget-box';
box.dataset.widgetType = widget.widget_type;
positionBox(box, widget);
const label = document.createElement('span');
label.className = 'widget-box-label';
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'widget-box-remove';
removeBtn.textContent = '×';
removeBtn.title = 'Remove this widget';
removeBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
removeBtn.addEventListener('click', (e) => { e.stopPropagation(); removeWidget(widget.id); });
box.appendChild(removeBtn);
const handle = document.createElement('div');
handle.className = 'widget-box-resize-handle';
handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); startDrag(e, widget, box, true); });
box.appendChild(handle);
box.addEventListener('pointerdown', (e) => startDrag(e, widget, box, false));
canvas.appendChild(box);
}
}
function renderAddButtons() {
const container = document.getElementById('add-widget-buttons');
container.innerHTML = '';
for (const type of gridState.widget_types) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary';
btn.textContent = `+ ${WIDGET_LABELS[type] || type}`;
btn.addEventListener('click', () => addWidget(type));
container.appendChild(btn);
}
const hint = document.getElementById('add-widget-hint');
hint.textContent = 'A new widget is placed in the first open space that fits it -- drag it afterward to reposition.';
}
async function loadWidgets() {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`);
if (!resp.ok) throw new Error(await apiError(resp));
gridState = await resp.json();
renderCanvas();
renderAddButtons();
renderControlBanner(gridState.control);
} catch (e) {
showStatus(false, e.message);
}
}
loadWidgets();
+65
View File
@@ -322,6 +322,71 @@ button.secondary:hover { background: var(--surface-alt); }
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
#widget-canvas-wrap {
--grid-cols: 8;
--grid-rows: 5;
width: 100%;
max-width: 640px;
aspect-ratio: var(--grid-cols) / var(--grid-rows);
border: 1px solid var(--border);
border-radius: 10px;
background-color: var(--surface-alt);
background-image:
linear-gradient(to right, var(--border) 1px, transparent 1px),
linear-gradient(to bottom, var(--border) 1px, transparent 1px);
background-size: calc(100% / var(--grid-cols)) calc(100% / var(--grid-rows));
}
#widget-canvas { position: relative; width: 100%; height: 100%; }
.widget-box {
position: absolute;
box-sizing: border-box;
border: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
cursor: grab;
user-select: none;
overflow: hidden;
}
.widget-box.dragging { cursor: grabbing; box-shadow: var(--shadow-hover); z-index: 2; }
.widget-box-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
pointer-events: none;
}
.widget-box-remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
line-height: 1;
border-radius: 50%;
border: none;
background: var(--overlay);
color: #fff;
cursor: pointer;
}
.widget-box-remove:hover { background: var(--overlay-hover); }
.widget-box-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
border-right: 3px solid var(--accent);
border-bottom: 3px solid var(--accent);
border-bottom-right-radius: 4px;
}
code {
background: var(--surface-alt);
color: var(--text);
+1
View File
@@ -1,4 +1,5 @@
<nav class="tabs">
<a href="/frames/{{ frame.id }}/layout" class="{% if active_tab == 'layout' %}active{% endif %}">Layout</a>
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/calendar"
+46
View File
@@ -0,0 +1,46 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Layout{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Widgets</h2>
<p class="sub">Drag a widget to move it, drag its bottom-right corner
to resize it -- like arranging widgets on a phone's home screen.
Widgets can't overlap. Each widget's own settings (which album,
which calendars, etc.) live on its type's own tab.</p>
<div id="widget-canvas-wrap">
<div id="widget-canvas"></div>
</div>
<p class="sub" id="widget-canvas-empty-hint" style="display: none; margin-top: 10px;">
Nothing placed yet -- add a widget below.</p>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Add a widget</h2>
<div id="add-widget-buttons" class="checkbox-row" style="gap: 10px; flex-wrap: wrap;"></div>
<p class="sub" id="add-widget-hint" style="margin-top: 8px;"></p>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/frame_layout.js"></script>
{% endblock %}
+226
View File
@@ -0,0 +1,226 @@
"""routers/api_widgets.py -- widget CRUD + grid placement. Bounds/
minimum-footprint/no-overlap validation is server-side and re-checked
regardless of what a client already believes is a valid placement (see
the module's own docstring), so this is exercised at the HTTP layer, not
just against grid.py's pure functions directly (those have no dedicated
coverage of their own -- this file and test_migrations.py's backfill
tests are what actually exercise them end to end).
Frame #1's auto-migrated widget (see migration.py's backfill) is a
single full-panel (0, 0, 8, 5) photos widget -- every test here starts
by shrinking or removing it to free up room, mirroring what a real user
would do on the placement canvas before adding a second widget."""
from __future__ import annotations
from app.models import Frame, FrameButtonAction, Widget
from .conftest import csrf_headers
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
def _shrink_default_widget(client, db_session, w=4, h=5) -> int:
"""Frees up the right-hand side of the grid for a second widget."""
widget_id = _widget_id(db_session)
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
return widget_id
def test_list_widgets_returns_grid_and_the_default_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/widgets")
assert resp.status_code == 200
data = resp.json()
assert data["orientation"] == "landscape"
assert data["grid"] == {"cols": 8, "rows": 5}
assert len(data["widgets"]) == 1
assert data["widgets"][0]["widget_type"] == "photos"
assert data["widgets"][0]["w"] == 8 and data["widgets"][0]["h"] == 5
def test_create_with_no_room_left_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets", json={"widget_type": "photos"}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "no open space" in resp.json()["detail"].lower()
def test_create_auto_places_in_freed_up_space(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
created = resp.json()
assert created["widget_type"] == "whiteboard"
assert created["x"] >= 4 # lands in the freed right-hand region, not overlapping the shrunk photos widget
assert (created["w"], created["h"]) == (2, 2) # grid.MIN_FOOTPRINT["whiteboard"]
widget = db_session.get(Widget, created["id"])
assert widget is not None and widget.frame_id == 1
# A default config row was created alongside it (see WIDGET_CONFIG_MODELS).
from app.models import WhiteboardWidgetConfig
assert db_session.get(WhiteboardWidgetConfig, widget.id) is not None
def test_create_with_explicit_placement_validates_bounds(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/widgets",
json={"widget_type": "photos", "x": 6, "y": 0, "w": 4, "h": 2},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "out of bounds" in resp.json()["detail"].lower()
def test_create_below_minimum_footprint_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/widgets",
json={"widget_type": "calendar", "x": 4, "y": 0, "w": 2, "h": 1},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "at least" in resp.json()["detail"].lower()
def test_create_overlapping_existing_widget_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
# Default widget still covers the full 8x5 grid -- any explicit placement overlaps it.
resp = client.post("/api/frames/1/widgets",
json={"widget_type": "whiteboard", "x": 0, "y": 0, "w": 2, "h": 2},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "overlaps" in resp.json()["detail"].lower()
def test_create_unknown_widget_type_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets", json={"widget_type": "video"}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "unknown widget type" in resp.json()["detail"].lower()
def test_move_widget_to_a_valid_rect(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
json={"x": 1, "y": 1, "w": 3, "h": 2}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget = db_session.get(Widget, widget_id)
assert (widget.x, widget.y, widget.w, widget.h) == (1, 1, 3, 2)
def test_move_rejects_overlap_with_another_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
whiteboard_id = create_resp.json()["id"]
# Try to move photos back over the whiteboard widget's space.
resp = client.patch(f"/api/frames/1/widgets/{photos_id}",
json={"x": 0, "y": 0, "w": 8, "h": 5}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "overlaps" in resp.json()["detail"].lower()
# Original placement is untouched after the rejected move.
widget = db_session.get(Widget, photos_id)
assert (widget.x, widget.y, widget.w, widget.h) == (0, 0, 4, 5)
assert whiteboard_id # sanity: the other widget really was created
def test_move_unknown_widget_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.patch("/api/frames/1/widgets/999999",
json={"x": 0, "y": 0, "w": 1, "h": 1}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_delete_widget_and_cascades_button_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=widget_id, action="advance", sort_order=0))
db_session.commit()
resp = client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
assert resp.status_code == 200
assert db_session.get(Widget, widget_id) is None
remaining = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert remaining == []
def test_delete_unknown_widget_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.delete("/api/frames/1/widgets/999999", headers=csrf_headers(client))
assert resp.status_code == 404
def test_widgets_scoped_to_their_own_frame(client, db_session):
"""A widget id from a different frame must 404, not silently operate
cross-frame -- same posture as photo_widget_config_or_404 and every
other frame-scoped lookup in this codebase."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
other_frame = Frame(name="Second frame", manage_token="tok-2", device_id="dev-2", device_token="dtok-2")
db_session.add(other_frame)
db_session.commit()
from app.models import Widget as W
other_widget = W(frame_id=other_frame.id, widget_type="photos", x=0, y=0, w=8, h=5, sort_order=0,
created_at=0)
db_session.add(other_widget)
db_session.commit()
resp = client.patch(f"/api/frames/1/widgets/{other_widget.id}",
json={"x": 0, "y": 0, "w": 1, "h": 1}, headers=csrf_headers(client))
assert resp.status_code == 404
# --- orientation change resets the widget layout (grid.grid_dims transposes) ---
def test_orientation_change_resets_multiple_widgets_to_one_full_panel_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
whiteboard_id = create_resp.json()["id"]
assert db_session.query(Widget).filter_by(frame_id=1).count() == 2
resp = client.post("/api/frames/1/config", data={"orientation": "portrait"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
remaining = db_session.query(Widget).filter_by(frame_id=1).all()
assert len(remaining) == 1
assert remaining[0].id == photos_id # first by sort_order survives
assert (remaining[0].x, remaining[0].y, remaining[0].w, remaining[0].h) == (0, 0, 5, 8) # full portrait panel
assert db_session.get(Widget, whiteboard_id) is None
frame = db_session.get(Frame, 1)
assert frame.orientation == "portrait"
def test_orientation_unchanged_leaves_widget_layout_alone(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/config", data={"orientation": "landscape"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget = db_session.query(Widget).filter_by(frame_id=1).one()
assert (widget.x, widget.y, widget.w, widget.h) == (0, 0, 4, 5) # untouched -- orientation didn't actually change
def test_orientation_change_with_no_widgets_is_a_no_op(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0
resp = client.post("/api/frames/1/config", data={"orientation": "portrait"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0