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:
@@ -22,7 +22,8 @@ import time
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, grid, quiet_hours
|
||||
@@ -30,7 +31,8 @@ from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame, Widget
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||
from .device import render_frame_preview_png
|
||||
|
||||
@@ -249,6 +251,86 @@ def api_frame_preview(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
BUTTONS = ("next", "back")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/buttons")
|
||||
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""Everything the button-assignment UI needs in one call: every
|
||||
widget on the frame with the actions its type supports (see
|
||||
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
|
||||
ordered list of (widget, action) bindings."""
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
widget_options = [
|
||||
{
|
||||
"id": w.id,
|
||||
"widget_type": w.widget_type,
|
||||
"actions": [
|
||||
{"action": action, "label": label}
|
||||
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
|
||||
],
|
||||
}
|
||||
for w in widgets
|
||||
]
|
||||
result = {"widgets": widget_options}
|
||||
for button in BUTTONS:
|
||||
rows = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
|
||||
return result
|
||||
|
||||
|
||||
class ButtonActionItem(BaseModel):
|
||||
widget_id: int
|
||||
action: str
|
||||
|
||||
|
||||
class ButtonActionsRequest(BaseModel):
|
||||
actions: list[ButtonActionItem]
|
||||
|
||||
|
||||
@router.put("/api/frames/{frame_id}/buttons/{button}")
|
||||
def api_buttons_save(
|
||||
button: str, body: ButtonActionsRequest,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replaces the whole ordered action list for one button in a single
|
||||
call -- simpler and more atomic than separate add/remove/reorder
|
||||
endpoints for what's normally a list of one to a handful of entries,
|
||||
and the UI always has the full list in hand anyway (see
|
||||
static/frame_config.js)."""
|
||||
if button not in BUTTONS:
|
||||
raise HTTPException(404, "No such button")
|
||||
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
|
||||
for item in body.actions:
|
||||
widget = widgets_by_id.get(item.widget_id)
|
||||
if widget is None:
|
||||
raise HTTPException(400, f"No such widget: {item.widget_id}")
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None or item.action not in module.ACTIONS:
|
||||
raise HTTPException(
|
||||
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
|
||||
)
|
||||
with frame_locked(db, frame.id):
|
||||
db.execute(
|
||||
delete(FrameButtonAction).where(
|
||||
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
|
||||
)
|
||||
)
|
||||
for i, item in enumerate(body.actions):
|
||||
db.add(FrameButtonAction(
|
||||
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
|
||||
sort_order=i, created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Shared display names for widget_type, everywhere one shows up in the
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' };
|
||||
|
||||
function showStatus(ok, message) {
|
||||
// While a <dialog> is open, its own .dialog-result container gets the
|
||||
// message instead of the page-level #result -- otherwise it lands
|
||||
|
||||
@@ -362,3 +362,165 @@ loadFirmwareCheck();
|
||||
// The server throttles actual Gitea API calls itself, so this poll is
|
||||
// cheap either way.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
|
||||
// --- Button assignments -----------------------------------------------
|
||||
// {widgets: [{id, widget_type, actions: [{action, label}]}], next: [...], back: [...]}
|
||||
// -- see api_frames.py's api_buttons_get. Each button's list is edited
|
||||
// client-side (add/remove/reorder) then PUT as a whole -- simpler than
|
||||
// separate reorder/add/remove endpoints for what's normally a handful of
|
||||
// entries, and this file already has the full list in hand after any
|
||||
// edit.
|
||||
let buttonsData = null;
|
||||
const BUTTONS = ['next', 'back'];
|
||||
|
||||
function widgetActionLabel(widgetId, action) {
|
||||
const w = buttonsData.widgets.find((w) => w.id === widgetId);
|
||||
if (!w) return `(deleted widget): ${action}`;
|
||||
const found = w.actions.find((a) => a.action === action);
|
||||
const actionLabel = found ? found.label : action;
|
||||
return `${WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
|
||||
}
|
||||
|
||||
function renderButtonList(button) {
|
||||
const list = document.getElementById(`button-actions-${button}`);
|
||||
const rows = buttonsData[button];
|
||||
list.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<li class="sub">Nothing assigned -- this button won’t do anything.</li>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'button-action-row';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = widgetActionLabel(row.widget_id, row.action);
|
||||
|
||||
const controls = document.createElement('span');
|
||||
controls.className = 'button-action-controls';
|
||||
|
||||
const up = document.createElement('button');
|
||||
up.type = 'button';
|
||||
up.className = 'icon-btn';
|
||||
up.textContent = '↑';
|
||||
up.title = 'Move up';
|
||||
up.disabled = i === 0;
|
||||
up.addEventListener('click', () => moveButtonAction(button, i, -1));
|
||||
|
||||
const down = document.createElement('button');
|
||||
down.type = 'button';
|
||||
down.className = 'icon-btn';
|
||||
down.textContent = '↓';
|
||||
down.title = 'Move down';
|
||||
down.disabled = i === rows.length - 1;
|
||||
down.addEventListener('click', () => moveButtonAction(button, i, 1));
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'icon-btn';
|
||||
remove.textContent = '×';
|
||||
remove.title = 'Remove';
|
||||
remove.addEventListener('click', () => removeButtonAction(button, i));
|
||||
|
||||
controls.appendChild(up);
|
||||
controls.appendChild(down);
|
||||
controls.appendChild(remove);
|
||||
li.appendChild(span);
|
||||
li.appendChild(controls);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function populateActionSelect(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||||
actionSel.innerHTML = '';
|
||||
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
|
||||
if (!w) return;
|
||||
w.actions.forEach((a) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.action;
|
||||
opt.textContent = a.label;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function populateWidgetSelect(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
widgetSel.innerHTML = '';
|
||||
buttonsData.widgets.forEach((w) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.id;
|
||||
opt.textContent = WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||||
widgetSel.appendChild(opt);
|
||||
});
|
||||
populateActionSelect(button);
|
||||
}
|
||||
|
||||
async function saveButtonActions(button) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Button assignments saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
|
||||
}
|
||||
}
|
||||
|
||||
function moveButtonAction(button, index, delta) {
|
||||
const rows = buttonsData[button];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
[rows[index], rows[target]] = [rows[target], rows[index]];
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
function removeButtonAction(button, index) {
|
||||
buttonsData[button].splice(index, 1);
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
function addButtonAction(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||||
if (!widgetSel.value || !actionSel.value) return;
|
||||
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
async function loadButtons() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
buttonsData = await resp.json();
|
||||
document.getElementById('button-assign-groups').style.display =
|
||||
buttonsData.widgets.length ? '' : 'none';
|
||||
document.getElementById('button-assign-empty-hint').style.display =
|
||||
buttonsData.widgets.length ? 'none' : '';
|
||||
BUTTONS.forEach((button) => {
|
||||
renderButtonList(button);
|
||||
populateWidgetSelect(button);
|
||||
});
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
BUTTONS.forEach((button) => {
|
||||
document.getElementById(`button-add-widget-${button}`)
|
||||
.addEventListener('change', () => populateActionSelect(button));
|
||||
document.getElementById(`button-add-${button}`)
|
||||
.addEventListener('click', () => addButtonAction(button));
|
||||
});
|
||||
|
||||
loadButtons();
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
let gridState = null; // last-loaded GET .../widgets response
|
||||
|
||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
|
||||
// WIDGET_LABELS comes from common.js (shared with frame_config.js's
|
||||
// button-assignment UI).
|
||||
|
||||
function renderControlBanner(control) {
|
||||
const banner = document.getElementById('control-banner');
|
||||
|
||||
@@ -257,6 +257,24 @@ input:focus, select:focus {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
|
||||
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.button-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
|
||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.button-action-add select { width: auto; margin-top: 0; }
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2 class="dialog-title">Whiteboard widget</h2>
|
||||
<h2 class="dialog-title">Whiteboard widget (alpha)</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Whiteboard source</h2>
|
||||
|
||||
@@ -53,6 +53,39 @@
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Button assignments</h2>
|
||||
<p class="sub">What the frame's physical NEXT and BACK buttons do --
|
||||
assign one or more widget actions to each, in the order they
|
||||
should run. A button with several actions runs all of them, in
|
||||
order, then the panel redraws once.</p>
|
||||
<p class="sub" id="button-assign-empty-hint" style="display: none;">
|
||||
Add a widget on the Layout tab first -- there's nothing to assign
|
||||
a button to yet.</p>
|
||||
|
||||
<div id="button-assign-groups">
|
||||
<div class="button-assign-group">
|
||||
<h3 class="button-assign-label">NEXT button</h3>
|
||||
<ul class="button-action-list" id="button-actions-next"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-next"></select>
|
||||
<select id="button-add-action-next"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-assign-group" style="margin-top: 20px;">
|
||||
<h3 class="button-assign-label">BACK button</h3>
|
||||
<ul class="button-action-list" id="button-actions-back"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-back"></select>
|
||||
<select id="button-add-action-back"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
you're linked to from that frame's Calendar tab, so a frame only
|
||||
shows calendars people have actually chosen to share with it.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV)</h2>
|
||||
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV) (alpha)</h2>
|
||||
<p class="sub">Credentials for whiteboard frame mode -- fetching a
|
||||
specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
|
||||
Any WebDAV server works, not just Nextcloud.</p>
|
||||
|
||||
@@ -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