Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 52s

Whiteboard rendering isn't fully reliable yet -- tag it (alpha)
everywhere it's user-facing (widget label, add-widget button, dialog
title, Settings' WebDAV section) via one shared WIDGET_LABELS map
(moved to common.js so both the Layout canvas and the new Configuration
tab section can use it).

Button assignments: a new "Button assignments" card on the
Configuration tab lets you assign an ordered list of (widget, action)
bindings to each physical NEXT/BACK button -- add/remove/reorder, all
autosaved. Backed by new GET/PUT /api/frames/{id}/buttons endpoints;
PUT replaces a button's whole list in one atomic, fully-validated call
rather than separate add/remove/reorder endpoints. Device-side
consumption already existed (routers/device.py's _run_button_actions);
this is the UI for what was previously only reachable via the default
migration mapping.
This commit is contained in:
2026-07-24 17:13:51 -04:00
parent 9c8a87e90d
commit 8b9f636cce
9 changed files with 454 additions and 5 deletions
+149
View File
@@ -0,0 +1,149 @@
"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API
(see routers/api_frames.py's api_buttons_get/api_buttons_save and
static/frame_config.js). Covers the CRUD/validation layer; multi-action
execution order and partial-failure-continues on an actual button press
are already exercised end-to-end in test_device_widget_dispatch.py and
routers/device.py's _run_button_actions -- this file doesn't re-test
device.py's dispatch, just that the assignment API stores/serves/
validates what the UI edits."""
from __future__ import annotations
import time
from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
from .conftest import csrf_headers, link_user, login, make_user
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
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 test_get_buttons_reflects_the_default_migration_mapping(client, db_session):
"""Frame #1's auto-migrated photos widget should already have NEXT ->
advance, BACK -> back from _default_button_actions (see
migration.py) -- the UI just needs to be able to see that default."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
data = resp.json()
assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"}
photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]}
assert photo_actions == {"advance", "back"}
assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}]
assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}]
def test_put_replaces_the_whole_list_in_order(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
board_widget = _add_whiteboard_widget(db_session, frame)
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [
{"widget_id": board_widget.id, "action": "check_now"},
{"widget_id": photo_widget.id, "action": "advance"},
],
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = db_session.query(FrameButtonAction).filter_by(
frame_id=frame.id, button="next"
).order_by(FrameButtonAction.sort_order).all()
assert [(r.widget_id, r.action) for r in rows] == [
(board_widget.id, "check_now"), (photo_widget.id, "advance"),
]
# BACK's own default mapping (photos -> back) is untouched by a PUT to next.
back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all()
assert len(back_rows) == 1
assert back_rows[0].action == "back"
def test_put_empty_list_clears_the_button(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0
def test_put_rejects_unknown_button_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_put_rejects_widget_from_another_frame(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time())
db_session.add(other)
db_session.flush()
other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(other_widget)
db_session.commit()
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": other_widget.id, "action": "advance"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
# Nothing partially applied -- the whole request is validated before any write.
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1
def test_put_rejects_action_the_widget_type_does_not_support(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": photo_widget.id, "action": "check_now"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
def test_deleting_a_widget_cascades_its_button_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0
def test_linked_user_can_view_and_control_can_save(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
def test_unrelated_user_cannot_view(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.cookies.clear()
login(client, "mallory")
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 404