Widget system Phase 4b: per-widget gear-icon config dialogs
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s

Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
This commit is contained in:
2026-07-24 14:31:24 -04:00
parent 63751a79ad
commit a33a3a71e4
35 changed files with 2335 additions and 1912 deletions
+93 -2
View File
@@ -17,12 +17,15 @@ from app.models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
PhotoWidgetConfig,
ServerSettings,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import make_user
def test_migrations_list_is_sequential_and_unique():
versions = [v for v, _ in MIGRATIONS]
@@ -170,11 +173,29 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
"""Exercises _migration_16's actual CREATE TABLE statements (the real
"existing production database upgrading past this migration"
scenario) rather than the fresh-install create_all() shortcut, which
every other test in this file goes through instead."""
every other test in this file goes through instead. frame_calendars
also gets rebuilt back to its pre-rekey (frame_id-keyed) shape,
matching what _migration_9/_migration_11 originally produced, so
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
migrate."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text(
"CREATE TABLE frame_calendars ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame(
@@ -183,8 +204,20 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "legacy-owner")
db_session.commit()
frame_id = frame.id
frame_id, user_id = frame.id, user.id
# An orphaned frame_calendars row (this frame's mode was never
# "calendar", so it has no calendar widget for _ensure_frame_
# calendars_rekeyed to attach it to) -- exercises that it's dropped
# cleanly rather than raising.
with db_module.engine.begin() as conn:
conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1)"
), {"frame_id": frame_id, "user_id": user_id})
run_migrations()
@@ -198,3 +231,61 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
assert config.album_id == "legacy-album"
assert config.current_asset_id == "legacy-asset"
assert config.queue == ["legacy-asset", "next-asset"]
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
"""A frame whose mode was "calendar" (not "photos") gets a calendar
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
rekeyed should then attach the pre-existing frame_id-keyed
FrameCalendar row inserted below to that widget's id, not drop it.
This is the regression case for the ordering bug the two migration
tests above's setup was designed to catch: the rekey must run AFTER
the widget backfill, not as a numbered migration racing ahead of
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text(
"CREATE TABLE frame_calendars ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame(
name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
mode="calendar", created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "cal-owner")
db_session.commit()
frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) "
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)"
), {"frame_id": frame_id, "user_id": user_id})
run_migrations()
widget = db_session.scalars(
select(Widget).where(Widget.frame_id == frame_id, Widget.widget_type == "calendar")
).one()
row = db_session.scalars(select(FrameCalendar).where(FrameCalendar.widget_id == widget.id)).one()
assert row.user_id == user_id
assert row.calendar_key == "ics"
assert row.calendar_label == "Legacy Cal"
assert row.included is True
assert row.color_index == 3