Drop the last legacy widget-system and shared-token auth scaffolding
Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s

Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.

Firmware: the captive portal's "Access Token" field and its NVS/
build_url plumbing only ever mattered for pointing new firmware at an
old pre-multi-frame server -- gone along with the server-side fallback
it fed. Version bump to publish the change.
This commit is contained in:
2026-08-04 18:33:29 +00:00
parent 2868087467
commit 1d39e439ff
23 changed files with 599 additions and 611 deletions
+13
View File
@@ -112,6 +112,19 @@ def link_user(db: Session, user: User, frame: Frame) -> None:
db.flush()
def claim_device(db: Session, frame: Frame, device_id: str = "001122334455",
token: str = "devtok-1") -> str:
"""Gives `frame` device credentials and returns the "id=...&token=..."
query string real firmware always sends -- require_device has no
fallback for a bare /frame/* request without ?id= (the old shared-
MANAGEMENT_TOKEN/no-id path this project used to resolve to a single
legacy frame is gone), so any device-facing test needs this."""
frame.device_id = device_id
frame.device_token = token
db.commit()
return f"id={device_id}&token={token}"
def login(client: TestClient, username: str, password: str = "testpass123") -> None:
resp = client.post("/login", data={"username": username, "password": password})
assert resp.status_code == 303, resp.text
+13 -10
View File
@@ -26,6 +26,8 @@ from app.models import (
Widget,
)
from .conftest import claim_device
EXPECTED_BYTES = 800 * 480 // 2
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
@@ -41,16 +43,17 @@ def _mock_immich(monkeypatch):
def test_unclaimed_frame_shows_placeholder(client, db_session):
frame = db_session.get(Frame, 1)
frame.owner_user_id = None
db_session.commit()
creds = claim_device(db_session, frame)
resp = client.get("/frame/image")
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/frame/image")
creds = claim_device(db_session, db_session.get(Frame, 1))
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
@@ -66,24 +69,24 @@ def test_configured_photo_widget_renders_and_advances_via_button(client, db_sess
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
creds = claim_device(db_session, frame)
_mock_immich(monkeypatch)
resp = client.get("/frame/image")
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1"
resp = client.post("/frame/advance")
resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
resp = client.post("/frame/back")
resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200
db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" # back undid it
@@ -94,11 +97,11 @@ def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
db_session.commit()
creds = claim_device(db_session, frame)
_mock_immich(monkeypatch)
plain = client.get("/frame/image").content
with_manage = client.get("/frame/image?manage=1").content
plain = client.get(f"/frame/image?{creds}").content
with_manage = client.get(f"/frame/image?{creds}&manage=1").content
assert len(with_manage) == EXPECTED_BYTES
assert with_manage != plain # the manage-QR overlay actually got composited in
+9 -8
View File
@@ -19,7 +19,7 @@ from app.models import (
WhiteboardWidgetConfig,
)
from .conftest import csrf_headers, link_user, login, make_user
from .conftest import claim_device, csrf_headers, link_user, login, make_user
EXPECTED_BYTES = 800 * 480 // 2
@@ -113,7 +113,8 @@ def test_save_logged_out_401s(client, db_session):
def test_global_next_is_a_noop_when_unset(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/frame/global-next")
creds = claim_device(db_session, db_session.get(Frame, 1))
resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
@@ -123,9 +124,9 @@ def test_global_next_runs_the_configured_action(client, db_session):
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.next_hold_action = "toggle_all_photo_locks"
db_session.commit()
creds = claim_device(db_session, frame)
resp = client.post("/frame/global-next")
resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
@@ -136,9 +137,9 @@ def test_global_back_runs_the_configured_action(client, db_session):
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.back_hold_action = "toggle_all_photo_locks"
db_session.commit()
creds = claim_device(db_session, frame)
resp = client.post("/frame/global-back")
resp = client.post(f"/frame/global-back?{creds}")
assert resp.status_code == 200
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
@@ -149,9 +150,9 @@ def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_ses
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.next_hold_action = "no_longer_exists"
db_session.commit()
creds = claim_device(db_session, frame)
resp = client.post("/frame/global-next")
resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
+105 -29
View File
@@ -28,6 +28,60 @@ from app.models import (
from .conftest import make_user
# Columns migration 41 drops from `frames` -- a fresh-install create_all()
# copy (what every db_session fixture starts from) already reflects
# today's models.py, i.e. the post-41 shape without these, so a test that
# wants to simulate a pre-41 database has to add them back itself before
# setting schema_version below 41 and calling run_migrations() -- same
# "frames isn't dropped/recreated by these replay tests" situation
# test_migration_29/30's own comments describe, just for columns being
# removed instead of added.
_LEGACY_FRAME_COLUMNS = [
"mode TEXT NOT NULL DEFAULT 'photos'",
"album_id TEXT NOT NULL DEFAULT ''",
"photo_order TEXT NOT NULL DEFAULT 'sequential'",
"display_mode TEXT NOT NULL DEFAULT 'crop_faces'",
"queue_target_len INTEGER NOT NULL DEFAULT 20",
"current_asset_id TEXT NOT NULL DEFAULT ''",
"current_asset_set_at REAL NOT NULL DEFAULT 0.0",
"queue TEXT NOT NULL DEFAULT '[]'",
"queue_cursor INTEGER NOT NULL DEFAULT 0",
"history TEXT NOT NULL DEFAULT '[]'",
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]'",
"calendar_view TEXT NOT NULL DEFAULT 'agenda'",
"calendar_week_start INTEGER NOT NULL DEFAULT 0",
"calendar_photo_inlay INTEGER NOT NULL DEFAULT 0",
"calendar_browse_offset INTEGER NOT NULL DEFAULT 0",
"calendar_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_cached_events TEXT",
"calendar_fetch_summary TEXT NOT NULL DEFAULT ''",
"calendar_weather_enabled INTEGER NOT NULL DEFAULT 0",
"calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'",
"calendar_weather_cities TEXT",
"calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_weather_cached TEXT",
"calendar_week_days INTEGER NOT NULL DEFAULT 7",
"calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'",
"calendar_week_start_offset INTEGER NOT NULL DEFAULT 0",
"calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0",
"calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
"calendar_tasks_calendar_key TEXT",
"calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_tasks_cached TEXT",
"whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
"whiteboard_url TEXT NOT NULL DEFAULT ''",
"whiteboard_checked_at REAL NOT NULL DEFAULT 0.0",
"whiteboard_cached_image BLOB",
"legacy_token_enabled INTEGER NOT NULL DEFAULT 0",
]
def _add_legacy_frame_columns(conn) -> None:
existing = {c["name"] for c in inspect(db_module.engine).get_columns("frames")}
for col_def in _LEGACY_FRAME_COLUMNS:
if col_def.split()[0] not in existing:
conn.execute(text(f"ALTER TABLE frames ADD COLUMN {col_def}"))
def test_migrations_list_is_sequential_and_unique():
versions = [v for v, _ in MIGRATIONS]
@@ -72,8 +126,6 @@ def test_expected_columns_exist_on_current_schema():
assert "webdav_base_url" in user_columns # migration 15
assert "webdav_username" in user_columns # migration 14
assert "calendar_caldav_url" in user_columns
assert "whiteboard_cached_image" in frame_columns # migration 14
assert "calendar_week_start_offset" in frame_columns
assert "name" in task_widget_columns # migration 19
assert "static_widget_configs" in inspector.get_table_names() # migration 20
assert "text_widget_configs" in inspector.get_table_names() # migration 21
@@ -106,14 +158,16 @@ def test_expected_columns_exist_on_current_schema():
assert "render_style" in calendar_widget_columns # migration 38
assert "theme" in frame_columns # migration 39
assert "font_scale" in widget_columns # migration 40
assert not {"mode", "album_id", "current_asset_id", "calendar_view", "whiteboard_url",
"legacy_token_enabled"} & frame_columns # migration 41
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
# --- widget system: fresh-install default widget, and migration 41's
# raw-SQL backfill safety net for a pre-widget-system database ---
def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_session):
def test_fresh_install_creates_a_default_photos_widget_with_default_buttons(db_session):
frame = db_session.get(Frame, 1)
assert frame.mode == "photos"
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
assert len(widgets) == 1
@@ -123,7 +177,7 @@ def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_sessi
config = db_session.get(PhotoWidgetConfig, widget.id)
assert config is not None
assert config.album_id == frame.album_id
assert config.album_id == ""
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
@@ -140,17 +194,26 @@ def test_rerunning_migrations_does_not_duplicate_widgets(db_session):
def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
"""Reproduces the old fixed 50/50 inlay split as two independent,
non-overlapping widgets instead of silently dropping the photo half
on upgrade -- see models.py's CalendarWidgetConfig docstring."""
frame = Frame(
name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
mode="calendar", orientation="landscape", calendar_view="week",
calendar_photo_inlay=True, album_id="album-123",
current_asset_id="asset-1", queue=["asset-1", "asset-2"],
created_at=time.time(),
)
on upgrade -- see models.py's CalendarWidgetConfig docstring. Exercises
_migration_41's raw-SQL backfill safety net: a frame whose legacy
Frame columns (pre-widget-system) still carry real data but which
somehow has no Widget yet."""
frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
orientation="landscape", created_at=time.time())
db_session.add(frame)
db_session.flush()
frame_id = frame.id
db_session.commit()
with db_module.engine.begin() as conn:
_add_legacy_frame_columns(conn)
conn.execute(text(
"UPDATE frames SET mode='calendar', calendar_view='week', calendar_photo_inlay=1, "
"album_id='album-123', current_asset_id='asset-1', queue='[\"asset-1\", \"asset-2\"]' "
"WHERE id = :id"
), {"id": frame_id})
conn.execute(text("UPDATE schema_version SET version = 40"))
run_migrations()
widgets = db_session.scalars(
@@ -180,15 +243,24 @@ def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session):
frame = Frame(
name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
mode="whiteboard", orientation="portrait",
whiteboard_url="https://example.com/board.whiteboard",
created_at=time.time(),
)
"""Exercises _migration_41's raw-SQL backfill safety net for a
whiteboard-mode legacy frame -- same shape as the calendar-inlay case
above, just the simpler single-widget mode dispatch branch."""
frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
orientation="portrait", created_at=time.time())
db_session.add(frame)
db_session.flush()
frame_id = frame.id
db_session.commit()
with db_module.engine.begin() as conn:
_add_legacy_frame_columns(conn)
conn.execute(text(
"UPDATE frames SET mode='whiteboard', "
"whiteboard_url='https://example.com/board.whiteboard' WHERE id = :id"
), {"id": frame_id})
conn.execute(text("UPDATE schema_version SET version = 40"))
run_migrations()
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
@@ -249,19 +321,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
_add_legacy_frame_columns(conn)
conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame(
name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
mode="photos", album_id="legacy-album", current_asset_id="legacy-asset",
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
created_at=time.time())
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "legacy-owner")
db_session.commit()
frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text(
"UPDATE frames SET mode='photos', album_id='legacy-album', current_asset_id='legacy-asset', "
"queue='[\"legacy-asset\", \"next-asset\"]' WHERE id = :id"
), {"id": frame_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
@@ -533,12 +609,11 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
_add_legacy_frame_columns(conn)
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(),
)
frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
created_at=time.time())
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "cal-owner")
@@ -546,6 +621,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id})
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)"
+9 -7
View File
@@ -14,7 +14,7 @@ from PIL import Image
from app.image_pipeline import logical_render_size
from app.models import Frame
from .conftest import link_user, login, make_user
from .conftest import claim_device, link_user, login, make_user
def test_now_displaying_404s_before_any_device_fetch(client, db_session):
@@ -27,8 +27,9 @@ def test_now_displaying_404s_before_any_device_fetch(client, db_session):
def test_frame_image_records_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get("/frame/image")
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200
resp = client.get("/api/frames/1/now-displaying")
@@ -43,18 +44,18 @@ def test_frame_image_records_now_displaying(client, db_session):
def test_advance_and_back_also_update_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
db_session.get(Frame, 1)
creds = claim_device(db_session, db_session.get(Frame, 1))
client.get("/frame/image")
client.get(f"/frame/image?{creds}")
first = client.get("/api/frames/1/now-displaying")
assert first.status_code == 200
resp = client.post("/frame/advance")
resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200
after_advance = client.get("/api/frames/1/now-displaying")
assert after_advance.status_code == 200
resp = client.post("/frame/back")
resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200
after_back = client.get("/api/frames/1/now-displaying")
assert after_back.status_code == 200
@@ -66,7 +67,8 @@ def test_now_displaying_visible_to_linked_user(client, db_session):
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
client.get("/frame/image")
creds = claim_device(db_session, frame)
client.get(f"/frame/image?{creds}")
client.cookies.clear()
login(client, "bob")