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
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:
+347
-211
@@ -22,13 +22,9 @@ from .db import SessionLocal, engine
|
||||
from .models import (
|
||||
Base,
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
TaskWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
from .widgets import default_button_actions
|
||||
@@ -902,6 +898,265 @@ def _migration_40(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
|
||||
|
||||
|
||||
def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None:
|
||||
"""Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets --
|
||||
called from _migration_41 while the legacy Frame columns it reads
|
||||
still physically exist, for the rare frame (if any) that somehow
|
||||
reached this migration without ever getting a Widget during the long
|
||||
window _ensure_widgets_backfilled ran unconditionally at every
|
||||
startup between migration 16 and this one. Same mode dispatch,
|
||||
including the calendar_photo_inlay two-widget split and the legacy
|
||||
tasks-source carryover. Has to be hand-rolled in raw SQL rather than
|
||||
reusing the old ORM helpers, since those read these columns off
|
||||
models.Frame, which no longer declares them as of this migration."""
|
||||
frame_id = frame_row["id"]
|
||||
orientation = frame_row["orientation"] or "landscape"
|
||||
cols, rows = grid.grid_dims(orientation)
|
||||
mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos"
|
||||
|
||||
def insert_widget(x, y, w, h, widget_type, sort_order):
|
||||
# border_style/border_thickness/border_color_index/font_scale
|
||||
# spelled out explicitly (migrations 26/40's own defaults)
|
||||
# rather than relied on implicitly -- they're real SQL-level
|
||||
# DEFAULTs in any database that reached this migration through
|
||||
# the normal upgrade path, but this stays correct even if that
|
||||
# ever stops being true.
|
||||
result = conn.execute(text(
|
||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
|
||||
"border_style, border_thickness, border_color_index, font_scale) "
|
||||
"VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, "
|
||||
"'none', 3, 0, 1.0)"
|
||||
), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h,
|
||||
"sort_order": sort_order, "created_at": now})
|
||||
return result.lastrowid
|
||||
|
||||
def insert_photo_config(widget_id):
|
||||
conn.execute(text(
|
||||
"INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, "
|
||||
"queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, "
|
||||
"excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, "
|
||||
":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, "
|
||||
":history, :excluded_asset_ids, 0)"
|
||||
), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"],
|
||||
"display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"],
|
||||
"current_asset_id": frame_row["current_asset_id"],
|
||||
"current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"],
|
||||
"queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"],
|
||||
"excluded_asset_ids": frame_row["excluded_asset_ids"]})
|
||||
|
||||
def insert_calendar_config(widget_id):
|
||||
conn.execute(text(
|
||||
"INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, "
|
||||
"cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, "
|
||||
"weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) "
|
||||
"VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, "
|
||||
":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, "
|
||||
":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')"
|
||||
), {"widget_id": widget_id, "view": frame_row["calendar_view"],
|
||||
"week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"],
|
||||
"checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"],
|
||||
"fetch_summary": frame_row["calendar_fetch_summary"],
|
||||
"weather_enabled": frame_row["calendar_weather_enabled"],
|
||||
"weather_units": frame_row["calendar_weather_units"],
|
||||
"weather_cities": frame_row["calendar_weather_cities"],
|
||||
"weather_checked_at": frame_row["calendar_weather_checked_at"],
|
||||
"weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"],
|
||||
"week_layout": frame_row["calendar_week_layout"],
|
||||
"week_start_offset": frame_row["calendar_week_start_offset"]})
|
||||
|
||||
def insert_whiteboard_config(widget_id):
|
||||
conn.execute(text(
|
||||
"INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, "
|
||||
"render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')"
|
||||
), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"],
|
||||
"checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]})
|
||||
|
||||
def insert_button_actions(widget_id, widget_type):
|
||||
if widget_type == "whiteboard":
|
||||
pairs = [("next", "check_now"), ("back", "check_now")]
|
||||
elif widget_type in ("photos", "calendar"):
|
||||
pairs = [("next", "advance"), ("back", "back")]
|
||||
else:
|
||||
pairs = []
|
||||
for button, action in pairs:
|
||||
conn.execute(text(
|
||||
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
|
||||
"VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)"
|
||||
), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action,
|
||||
"created_at": now})
|
||||
|
||||
def maybe_add_tasks_widget(existing_rects, next_sort_order):
|
||||
if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]:
|
||||
return
|
||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||
rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h)
|
||||
if rect is None:
|
||||
logger.warning(
|
||||
"Frame %d had a legacy task list configured but no open grid space for a "
|
||||
"standalone tasks widget during backfill -- its task source was dropped", frame_id
|
||||
)
|
||||
return
|
||||
x, y, w, h = rect
|
||||
widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order)
|
||||
conn.execute(text(
|
||||
"INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, "
|
||||
"render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')"
|
||||
), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"],
|
||||
"cached": frame_row["calendar_tasks_cached"]})
|
||||
conn.execute(text(
|
||||
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
|
||||
"VALUES (:widget_id, :user_id, :calendar_key, 1)"
|
||||
), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"],
|
||||
"calendar_key": frame_row["calendar_tasks_calendar_key"]})
|
||||
|
||||
if mode == "calendar" and frame_row["calendar_photo_inlay"]:
|
||||
half = cols // 2
|
||||
cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0)
|
||||
photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1)
|
||||
insert_calendar_config(cal_widget_id)
|
||||
insert_photo_config(photo_widget_id)
|
||||
insert_button_actions(cal_widget_id, "calendar")
|
||||
maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2)
|
||||
return
|
||||
|
||||
widget_id = insert_widget(0, 0, cols, rows, mode, 0)
|
||||
if mode == "photos":
|
||||
insert_photo_config(widget_id)
|
||||
elif mode == "calendar":
|
||||
insert_calendar_config(widget_id)
|
||||
elif mode == "whiteboard":
|
||||
insert_whiteboard_config(widget_id)
|
||||
insert_button_actions(widget_id, mode)
|
||||
if mode == "calendar":
|
||||
maybe_add_tasks_widget([(0, 0, cols, rows)], 1)
|
||||
|
||||
|
||||
def _migration_41(conn) -> None:
|
||||
"""Drops the legacy per-mode Frame columns the widget system
|
||||
(migration 16) superseded -- mode, the photo-queue fields (album_id/
|
||||
photo_order/display_mode/queue_target_len/current_asset_id/
|
||||
current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids),
|
||||
every calendar_* field, every whiteboard_* field, and
|
||||
legacy_token_enabled (models.py's own removal, alongside auth.py
|
||||
dropping the shared MANAGEMENT_TOKEN device/browser fallback it
|
||||
gated -- see auth.py's module docstring) -- see docs/widgets.md's
|
||||
Known Gaps, which deliberately left this open as a much larger blast
|
||||
radius than this project's usual same-migration-drop convention.
|
||||
|
||||
_ensure_widgets_backfilled ran unconditionally at the end of every
|
||||
startup from migration 16 until this one, so in practice every frame
|
||||
already has a Widget built from these columns' values by now; the
|
||||
backfill loop below (_raw_backfill_frame_widgets) is the same safety
|
||||
net migration 17/18 used for their own column drops, covering the
|
||||
edge case of a frame that somehow reached this point with none (e.g.
|
||||
a very old, never-restarted backup).
|
||||
|
||||
Guarded on "mode" existing, same reasoning as migration 26/27/29/
|
||||
30/40's own comments: frames IS dropped/recreated here (unlike
|
||||
widgets/photo_widget_configs, which those migrations left alone),
|
||||
but a fresh-install create_all() copy already reflects today's
|
||||
models.py -- i.e. the post-this-migration shape, missing "mode"
|
||||
entirely -- so a test replaying migrations 16+ from an old
|
||||
schema_version without also reconstructing frames' pre-41 columns
|
||||
would otherwise hit "no such column: mode" here even though it has
|
||||
nothing to do with what that test is actually exercising."""
|
||||
if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}:
|
||||
return
|
||||
now = time.time()
|
||||
frame_rows = conn.execute(text(
|
||||
"SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, "
|
||||
"current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, "
|
||||
"calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, "
|
||||
"calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, "
|
||||
"calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, "
|
||||
"calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, "
|
||||
"calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, "
|
||||
"calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, "
|
||||
"whiteboard_cached_image FROM frames"
|
||||
)).mappings().all()
|
||||
|
||||
for row in frame_rows:
|
||||
has_widget = conn.execute(
|
||||
text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]}
|
||||
).first()
|
||||
if has_widget is None:
|
||||
_raw_backfill_frame_widgets(conn, row, now)
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE frames_new ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"device_id TEXT UNIQUE, "
|
||||
"name TEXT NOT NULL DEFAULT '', "
|
||||
"owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||
"controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||
"device_token TEXT NOT NULL, "
|
||||
"device_token_ack INTEGER NOT NULL DEFAULT 0, "
|
||||
"manage_token TEXT NOT NULL UNIQUE, "
|
||||
"claimed_at REAL, "
|
||||
"created_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"immich_url TEXT NOT NULL DEFAULT '', "
|
||||
"immich_api_key TEXT NOT NULL DEFAULT '', "
|
||||
"refresh_interval_s INTEGER NOT NULL DEFAULT 3600, "
|
||||
"quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||
"quiet_hours_start TEXT NOT NULL DEFAULT '22:00', "
|
||||
"quiet_hours_end TEXT NOT NULL DEFAULT '07:00', "
|
||||
"timezone TEXT NOT NULL DEFAULT 'UTC', "
|
||||
"orientation TEXT NOT NULL DEFAULT 'landscape', "
|
||||
"palette_rgb TEXT, "
|
||||
"color_boost REAL NOT NULL DEFAULT 1.0, "
|
||||
"contrast_boost REAL NOT NULL DEFAULT 1.0, "
|
||||
"dither_strength REAL NOT NULL DEFAULT 1.0, "
|
||||
"photo_palette_rgb TEXT, "
|
||||
"photo_dither_strength REAL NOT NULL DEFAULT 1.0, "
|
||||
"theme TEXT NOT NULL DEFAULT 'classic', "
|
||||
"battery_percent INTEGER NOT NULL DEFAULT -1, "
|
||||
"battery_as_of REAL NOT NULL DEFAULT 0.0, "
|
||||
"battery_history TEXT NOT NULL DEFAULT '[]', "
|
||||
"last_seen REAL NOT NULL DEFAULT 0.0, "
|
||||
"device_firmware_version TEXT NOT NULL DEFAULT '', "
|
||||
"device_board_variant TEXT NOT NULL DEFAULT '', "
|
||||
"battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, "
|
||||
"battery_alert_sent INTEGER NOT NULL DEFAULT 0, "
|
||||
"firmware_available_version TEXT NOT NULL DEFAULT '', "
|
||||
"firmware_update_repo_url TEXT NOT NULL DEFAULT '', "
|
||||
"firmware_auto_update INTEGER NOT NULL DEFAULT 0, "
|
||||
"firmware_update_token TEXT NOT NULL DEFAULT '', "
|
||||
"firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', "
|
||||
"hold_duration_ms INTEGER NOT NULL DEFAULT 3000, "
|
||||
"next_hold_action TEXT, "
|
||||
"back_hold_action TEXT, "
|
||||
"last_cycled_layout_id INTEGER, "
|
||||
"last_displayed_image BLOB, "
|
||||
"last_displayed_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"stats_first_seen REAL NOT NULL DEFAULT 0.0, "
|
||||
"stats_device_wakes INTEGER NOT NULL DEFAULT 0, "
|
||||
"stats_photos_displayed INTEGER NOT NULL DEFAULT 0, "
|
||||
"stats_photos_removed INTEGER NOT NULL DEFAULT 0, "
|
||||
"stats_battery_reports INTEGER NOT NULL DEFAULT 0, "
|
||||
"stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, "
|
||||
"stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, "
|
||||
"stats_config_saves INTEGER NOT NULL DEFAULT 0)"
|
||||
))
|
||||
kept_columns = (
|
||||
"id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, "
|
||||
"manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, "
|
||||
"quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, "
|
||||
"color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, "
|
||||
"battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, "
|
||||
"device_board_variant, battery_alert_threshold_pct, battery_alert_sent, "
|
||||
"firmware_available_version, firmware_update_repo_url, firmware_auto_update, "
|
||||
"firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, "
|
||||
"hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, "
|
||||
"last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, "
|
||||
"stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, "
|
||||
"stats_ota_updates_applied, stats_config_saves"
|
||||
)
|
||||
conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames"))
|
||||
conn.execute(text("DROP TABLE frames"))
|
||||
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -943,6 +1198,7 @@ MIGRATIONS = [
|
||||
(38, _migration_38),
|
||||
(39, _migration_39),
|
||||
(40, _migration_40),
|
||||
(41, _migration_41),
|
||||
]
|
||||
|
||||
|
||||
@@ -959,18 +1215,53 @@ def run_migrations() -> None:
|
||||
# already added (e.g. "duplicate column name"). Jump
|
||||
# straight to the latest version instead.
|
||||
_migration_1(conn)
|
||||
latest = MIGRATIONS[-1][0]
|
||||
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
|
||||
current = MIGRATIONS[-1][0]
|
||||
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
|
||||
else:
|
||||
current = row[0]
|
||||
for version, fn in MIGRATIONS:
|
||||
if version > current:
|
||||
logger.info("Applying schema migration %d", version)
|
||||
fn(conn)
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
|
||||
# Each migration commits in its own transaction (rather than the
|
||||
# whole batch sharing one, like this used to) so that _migration_41
|
||||
# can get a connection with no transaction pending on it yet --
|
||||
# SQLite only honors toggling PRAGMA foreign_keys when issued as a
|
||||
# connection's literal first statement, and it needs that off for
|
||||
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
|
||||
# for widgets/frame_button_actions/user_frames/etc, so leaving
|
||||
# enforcement on there would cascade-delete every frame's widgets,
|
||||
# not just the columns that migration means to drop). A crash
|
||||
# partway through now simply leaves schema_version at the last
|
||||
# migration that actually completed, same as it always could
|
||||
# between separate runs of this function.
|
||||
for version, fn in MIGRATIONS:
|
||||
if version <= current:
|
||||
continue
|
||||
logger.info("Applying schema migration %d", version)
|
||||
with engine.connect() as conn:
|
||||
if fn is _migration_41:
|
||||
# Executing this before anything else auto-begins
|
||||
# SQLAlchemy's own Transaction bookkeeping too, so an
|
||||
# explicit conn.begin() below would conflict with it --
|
||||
# fn(conn) and the version UPDATE just ride that same
|
||||
# auto-begun transaction, committed explicitly at the end.
|
||||
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
||||
fn(conn)
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
conn.commit()
|
||||
if fn is _migration_41:
|
||||
# Restore it before this connection goes back to the
|
||||
# pool -- otherwise a later checkout of the same
|
||||
# underlying DBAPI connection (the connect-event
|
||||
# listener in db.py only fires for a genuinely new one)
|
||||
# would silently run with enforcement off. Has to
|
||||
# happen AFTER commit(), same "no pending transaction"
|
||||
# requirement as the OFF toggle above -- issuing it
|
||||
# before the commit is exactly the mid-transaction
|
||||
# no-op this migration exists to work around in the
|
||||
# first place, just in the other direction.
|
||||
conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||
|
||||
_ensure_frame_one()
|
||||
_ensure_server_settings()
|
||||
_ensure_widgets_backfilled()
|
||||
_ensure_frame_calendars_rekeyed()
|
||||
|
||||
|
||||
@@ -985,11 +1276,11 @@ def new_manage_token() -> str:
|
||||
def _ensure_frame_one() -> None:
|
||||
"""First boot only (frames table empty): create frame #1 -- imported
|
||||
verbatim from a legacy config.json if one exists, otherwise fresh
|
||||
defaults. Either way it's the legacy-token frame: the deployed
|
||||
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
|
||||
and require_device resolves those requests here. The frames-nonempty
|
||||
guard makes this idempotent; config.json is left untouched as the
|
||||
rollback path."""
|
||||
defaults -- plus a single full-panel photos widget carrying over
|
||||
whatever photo-queue state that file had (the widget system's
|
||||
equivalent of what used to live directly on Frame; see migration
|
||||
41). The frames-nonempty guard makes this idempotent; config.json is
|
||||
left untouched as the rollback path."""
|
||||
with SessionLocal() as db:
|
||||
if db.scalars(select(Frame).limit(1)).first() is not None:
|
||||
return
|
||||
@@ -1002,26 +1293,15 @@ def _ensure_frame_one() -> None:
|
||||
device_id=None,
|
||||
device_token=new_device_token(),
|
||||
manage_token=new_manage_token(),
|
||||
legacy_token_enabled=True,
|
||||
created_at=time.time(),
|
||||
immich_url=cfg.immich_url,
|
||||
immich_api_key=cfg.immich_api_key,
|
||||
album_id=cfg.album_id,
|
||||
order=cfg.order,
|
||||
refresh_interval_s=cfg.refresh_interval_s,
|
||||
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
||||
quiet_hours_start=cfg.quiet_hours_start,
|
||||
quiet_hours_end=cfg.quiet_hours_end,
|
||||
timezone=cfg.timezone,
|
||||
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
||||
orientation=cfg.orientation,
|
||||
queue_target_len=cfg.queue_target_len,
|
||||
current_asset_id=cfg.current_asset_id,
|
||||
current_asset_set_at=cfg.current_asset_set_at,
|
||||
queue=list(cfg.queue),
|
||||
queue_cursor=cfg.queue_cursor,
|
||||
history=list(cfg.history),
|
||||
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
||||
battery_percent=cfg.battery_percent,
|
||||
battery_as_of=cfg.battery_as_of,
|
||||
battery_history=[list(pair) for pair in cfg.battery_history],
|
||||
@@ -1044,11 +1324,31 @@ def _ensure_frame_one() -> None:
|
||||
stats_config_saves=cfg.stats.config_saves,
|
||||
)
|
||||
db.add(frame)
|
||||
db.flush() # assign frame.id for the battery log rows
|
||||
db.flush() # assign frame.id for the battery log rows + widget FK
|
||||
|
||||
for pair in cfg.battery_log:
|
||||
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
||||
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
|
||||
sort_order=0, created_at=time.time())
|
||||
db.add(widget)
|
||||
db.flush() # assign widget.id for the config row's FK
|
||||
db.add(PhotoWidgetConfig(
|
||||
widget_id=widget.id,
|
||||
album_id=cfg.album_id,
|
||||
order=cfg.order,
|
||||
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
||||
queue_target_len=cfg.queue_target_len,
|
||||
current_asset_id=cfg.current_asset_id,
|
||||
current_asset_set_at=cfg.current_asset_set_at,
|
||||
queue=list(cfg.queue),
|
||||
queue_cursor=cfg.queue_cursor,
|
||||
history=list(cfg.history),
|
||||
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
||||
))
|
||||
db.add_all(default_button_actions(frame.id, widget.id, "photos"))
|
||||
|
||||
db.commit()
|
||||
|
||||
# The single legacy firmware slot becomes frame #1's per-frame slot.
|
||||
@@ -1078,168 +1378,6 @@ def _ensure_server_settings() -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
|
||||
return PhotoWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
album_id=frame.album_id,
|
||||
order=frame.order,
|
||||
display_mode=frame.display_mode,
|
||||
queue_target_len=frame.queue_target_len,
|
||||
current_asset_id=frame.current_asset_id,
|
||||
current_asset_set_at=frame.current_asset_set_at,
|
||||
queue=list(frame.queue),
|
||||
queue_cursor=frame.queue_cursor,
|
||||
history=list(frame.history),
|
||||
excluded_asset_ids=list(frame.excluded_asset_ids),
|
||||
)
|
||||
|
||||
|
||||
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
|
||||
return CalendarWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
view=frame.calendar_view,
|
||||
week_start=frame.calendar_week_start,
|
||||
browse_offset=frame.calendar_browse_offset,
|
||||
checked_at=frame.calendar_checked_at,
|
||||
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
|
||||
fetch_summary=frame.calendar_fetch_summary,
|
||||
weather_enabled=frame.calendar_weather_enabled,
|
||||
weather_units=frame.calendar_weather_units,
|
||||
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
|
||||
weather_checked_at=frame.calendar_weather_checked_at,
|
||||
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
|
||||
week_days=frame.calendar_week_days,
|
||||
week_layout=frame.calendar_week_layout,
|
||||
week_start_offset=frame.calendar_week_start_offset,
|
||||
# tasks_* deliberately not carried over -- see
|
||||
# _task_config_and_list_from_frame, a sibling standalone widget
|
||||
# now, not part of this config.
|
||||
)
|
||||
|
||||
|
||||
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
|
||||
"""Only ever called for a frame whose legacy calendar_tasks_* columns
|
||||
(see Frame's own docstring on those -- a dead pre-widget-system
|
||||
field set, same status as calendar_photo_inlay below) still carry a
|
||||
configured source -- i.e. a database jumping straight from before
|
||||
the widget system existed to after tasks became their own
|
||||
multi-list widget type in a single upgrade, skipping both
|
||||
intermediate periods where it would have lived on
|
||||
CalendarWidgetConfig (_migration_17's extraction) and then a
|
||||
single-source TaskWidgetConfig (_migration_18's extraction) instead.
|
||||
Reproduces the same shape those two migrations arrive at directly:
|
||||
a bare cache-state config plus one included FrameTaskList row."""
|
||||
cfg = TaskWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
checked_at=frame.calendar_tasks_checked_at,
|
||||
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
||||
)
|
||||
task_list = FrameTaskList(
|
||||
widget_id=widget_id,
|
||||
user_id=frame.calendar_tasks_user_id,
|
||||
calendar_key=frame.calendar_tasks_calendar_key,
|
||||
included=True,
|
||||
)
|
||||
return cfg, task_list
|
||||
|
||||
|
||||
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
|
||||
return WhiteboardWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
user_id=frame.whiteboard_user_id,
|
||||
url=frame.whiteboard_url,
|
||||
checked_at=frame.whiteboard_checked_at,
|
||||
cached_image=frame.whiteboard_cached_image,
|
||||
)
|
||||
|
||||
|
||||
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
||||
"""Only relevant for a database jumping straight from before the
|
||||
widget system existed to after tasks became their own widget type
|
||||
in one upgrade (see _task_config_and_list_from_frame) --
|
||||
frame.calendar_tasks_* is the dead legacy field set otherwise.
|
||||
Requires both calendar_key and user_id (FrameTaskList.user_id is
|
||||
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
|
||||
Auto-placed in whatever open space is left after the widget(s) above
|
||||
it in _backfill_frame_widgets claimed theirs, same find_open_rect
|
||||
logic a manual "add widget" uses; silently dropped (logged) if none
|
||||
fits, same as this migration having nowhere else to put it either."""
|
||||
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
|
||||
return
|
||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
|
||||
if rect is None:
|
||||
logger.warning(
|
||||
"Frame %d had a legacy task list configured but no open grid space for a "
|
||||
"standalone tasks widget during backfill -- its task source was dropped", frame.id
|
||||
)
|
||||
return
|
||||
x, y, w, h = rect
|
||||
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
||||
sort_order=next_sort_order, created_at=time.time())
|
||||
db.add(task_widget)
|
||||
db.flush()
|
||||
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
|
||||
db.add(cfg)
|
||||
db.add(task_list)
|
||||
|
||||
|
||||
def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
|
||||
|
||||
if mode == "calendar" and frame.calendar_photo_inlay:
|
||||
# Reproduces the old fixed 50/50 inlay split as two independent
|
||||
# widgets instead of silently dropping half of what the frame was
|
||||
# showing -- see models.py's CalendarWidgetConfig docstring on why
|
||||
# "photo inlay" isn't a widget-system concept anymore otherwise.
|
||||
half = cols // 2
|
||||
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
|
||||
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
|
||||
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
|
||||
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
|
||||
db.add_all([cal_widget, photo_widget])
|
||||
db.flush() # assign ids before the FK'd config rows reference them
|
||||
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
||||
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
||||
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||
_maybe_add_legacy_tasks_widget(
|
||||
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
||||
)
|
||||
return
|
||||
|
||||
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
||||
sort_order=0, created_at=time.time())
|
||||
db.add(widget)
|
||||
db.flush()
|
||||
if mode == "photos":
|
||||
db.add(_photo_config_from_frame(frame, widget.id))
|
||||
elif mode == "calendar":
|
||||
db.add(_calendar_config_from_frame(frame, widget.id))
|
||||
elif mode == "whiteboard":
|
||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||
db.add_all(default_button_actions(frame.id, widget.id, mode))
|
||||
if mode == "calendar":
|
||||
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
||||
|
||||
|
||||
def _ensure_widgets_backfilled() -> None:
|
||||
"""Every frame needs at least one Widget once the widget system is
|
||||
live -- runs unconditionally after every startup (both a from-scratch
|
||||
_ensure_frame_one() install and an existing-install upgrade past
|
||||
_migration_16 land here) and is a no-op for any frame that already
|
||||
has one. Builds a widget that reproduces the frame's current mode/
|
||||
settings/state exactly, so upgrading never changes what a frame
|
||||
displays or what its physical buttons do on its own."""
|
||||
with SessionLocal() as db:
|
||||
for frame in db.scalars(select(Frame)).all():
|
||||
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
|
||||
if has_widget is not None:
|
||||
continue
|
||||
_backfill_frame_widgets(db, frame)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_frame_calendars_rekeyed() -> None:
|
||||
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
|
||||
hold more than one independent calendar widget (see the widget
|
||||
@@ -1252,27 +1390,25 @@ def _ensure_frame_calendars_rekeyed() -> None:
|
||||
and savable even while a frame's old `mode` was "photos"), not real
|
||||
live configuration.
|
||||
|
||||
Deliberately NOT a numbered migration: this needs each frame's
|
||||
calendar widget to already exist to know what to re-key against, and
|
||||
those widget rows aren't created by a schema migration at all --
|
||||
they come from _ensure_widgets_backfilled() above, which (like this
|
||||
function) runs unconditionally after every startup rather than being
|
||||
tracked by schema_version. Running this as a numbered migration
|
||||
would execute it *before* that backfill during a real upgrade (the
|
||||
numbered-migration loop runs first, see run_migrations), silently
|
||||
dropping every row -- caught by test_migrations.py actually exercising
|
||||
the raw-SQL upgrade path instead of the fresh-install create_all()
|
||||
shortcut every other test in that file takes.
|
||||
Deliberately NOT a numbered migration: every frame's calendar widget
|
||||
must already exist to know what to re-key against, and for a genuine
|
||||
pre-widget-system database those rows only exist once _migration_41's
|
||||
own backfill has run (a step inside that migration, not before it).
|
||||
A numbered migration for this would race ahead of that backfill (the
|
||||
numbered-migration loop runs top to bottom in one pass, see
|
||||
run_migrations), silently dropping every row -- caught by
|
||||
test_migrations.py actually exercising the raw-SQL upgrade path
|
||||
instead of the fresh-install create_all() shortcut every other test
|
||||
in that file takes.
|
||||
|
||||
Runs unconditionally after every startup, like _ensure_widgets_
|
||||
backfilled; a no-op the moment frame_calendars is already
|
||||
widget_id-shaped (every fresh install, and any existing install
|
||||
after its first run past this code) -- SQLite can't ALTER a column's
|
||||
FK target or drop a column that's part of an index/FK constraint, so
|
||||
when it isn't a no-op this is the standard SQLite "rebuild" pattern:
|
||||
create the new-shape table, copy matching rows across (joining to
|
||||
find each row's calendar widget), drop the old table, rename the new
|
||||
one into place."""
|
||||
Runs unconditionally after every startup instead; a no-op the moment
|
||||
frame_calendars is already widget_id-shaped (every fresh install,
|
||||
and any existing install after its first run past this code) --
|
||||
SQLite can't ALTER a column's FK target or drop a column that's part
|
||||
of an index/FK constraint, so when it isn't a no-op this is the
|
||||
standard SQLite "rebuild" pattern: create the new-shape table, copy
|
||||
matching rows across (joining to find each row's calendar widget),
|
||||
drop the old table, rename the new one into place."""
|
||||
inspector = inspect(engine)
|
||||
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
|
||||
if "widget_id" in columns:
|
||||
|
||||
Reference in New Issue
Block a user