Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
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:
@@ -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
|
||||
Reference in New Issue
Block a user