Widget system Phase 2: full cutover to widget-based rendering
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s

device.py's mode-keyed dispatch is replaced by a real compositor:
load a frame's widgets, compute pixel rects via app/grid.py, render
each through its widget module, and composite with render_panel.
Physical NEXT/BACK buttons now execute each frame's assigned
FrameButtonAction rows instead of one hardcoded per-mode action.

api_frames.py, manage.py, and common.py's build_manage_content are
repointed to read/write the frame's widget config rows instead of
the old Frame columns, and every settings page (Photos/Calendar/
Whiteboard tabs) now pre-fills its form from the same widget config
the write endpoints actually save to -- previously the read and
write sides would have silently diverged. The old mode selector and
photo-inlay checkbox are removed along with their now-inert wiring;
arbitrary widget placement subsumes what the fixed inlay split did.

Ships together with Phase 1 (per-type render/action modules) since
splitting the read/write cutover across deploys would have left
settings changes with no visible effect.
This commit is contained in:
2026-07-24 09:26:28 -04:00
parent f48daa71c8
commit 37bd657299
26 changed files with 1070 additions and 791 deletions
+81 -22
View File
@@ -6,7 +6,16 @@ not just a helper function's logic."""
from __future__ import annotations
from app.models import Frame, FrameCalendar, User
import time
from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
User,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import csrf_headers, link_user, login, make_user
@@ -21,26 +30,53 @@ def _setup_two_linked_users(client, db_session) -> Frame:
return frame
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
"""Frame #1's auto-migrated widget is a photos widget (its mode was
"photos" before the widget system existed) -- these tests need a
whiteboard widget too, which nothing creates yet until the
widget-placement UI (a later phase) ships, so it's added directly
here the same way the widget unit tests do."""
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _add_calendar_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
# --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
# alice is still logged in from /setup
resp = client.post("/api/frames/1/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.whiteboard_user_id is not None
assert frame.whiteboard_url == "https://cloud.example.com/dav/files/alice/board.whiteboard"
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.user_id is not None
assert cfg.url == "https://cloud.example.com/dav/files/alice/board.whiteboard"
def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
"""bob has no way to point the frame at someone else's account --
there's no target-user field in the request at all, so a "set" call
from bob always attaches to bob, even if he pastes alice's URL."""
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
@@ -49,13 +85,14 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
bob_row = db_session.query(User).filter_by(username="bob").one()
assert frame.whiteboard_user_id == bob_row.id
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.user_id == bob_row.id
def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"},
headers=csrf_headers(client))
@@ -64,20 +101,22 @@ def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.whiteboard_url == ""
assert frame.whiteboard_user_id is None
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.url == ""
assert cfg.user_id is None
def test_whiteboard_source_rejects_non_http_url(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
_add_whiteboard_widget(db_session, frame)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"},
headers=csrf_headers(client))
assert resp.status_code == 400
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
_add_whiteboard_widget(db_session, frame)
make_user(db_session, "mallory") # exists, but never linked to frame 1
client.cookies.clear()
login(client, "mallory")
@@ -87,10 +126,22 @@ def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
assert resp.status_code == 404
def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session):
"""Distinct from the unlinked-user 404 above -- this is a linked,
fully-permitted owner hitting the endpoint on a frame that simply
doesn't have a whiteboard widget yet (frame #1's auto-migrated
widget is a photos widget)."""
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"},
headers=csrf_headers(client))
assert resp.status_code == 404
# --- tasks-source ---
def test_tasks_source_set_always_targets_the_caller(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
@@ -99,13 +150,14 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
frame = db_session.get(Frame, 1)
assert frame.calendar_tasks_user_id == bob_row.id
assert frame.calendar_tasks_calendar_key == "caldav:/some/tasks/"
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id == bob_row.id
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"},
headers=csrf_headers(client))
@@ -114,9 +166,16 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.calendar_tasks_user_id is None
assert frame.calendar_tasks_calendar_key is None
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id is None
assert cfg.tasks_calendar_key is None
def test_tasks_source_404s_when_frame_has_no_calendar_widget(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
headers=csrf_headers(client))
assert resp.status_code == 404
# --- calendar-select ---