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.
1438 lines
72 KiB
Python
1438 lines
72 KiB
Python
"""Schema versioning + one-time import of a legacy config.json deployment.
|
|
|
|
Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
|
|
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
|
|
just create_all. DDL stays dialect-neutral so a future move to Postgres
|
|
is a DATABASE_URL change, not a rewrite.
|
|
|
|
Run at import time from main.py, before any request is served.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
import shutil
|
|
import time
|
|
|
|
from sqlalchemy import inspect, select, text
|
|
|
|
from . import config, grid
|
|
from .db import SessionLocal, engine
|
|
from .models import (
|
|
Base,
|
|
BatteryLog,
|
|
Frame,
|
|
PhotoWidgetConfig,
|
|
ServerSettings,
|
|
Widget,
|
|
)
|
|
from .widgets import default_button_actions
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _migration_1(conn) -> None:
|
|
Base.metadata.create_all(bind=conn)
|
|
|
|
|
|
def _migration_2(conn) -> None:
|
|
"""Adds email (users) and battery-alert threshold (frames) columns,
|
|
plus the new server_settings/password_reset_tokens tables. ALTER
|
|
TABLE ADD COLUMN with a default is safe on SQLite against a live,
|
|
already-populated database -- existing rows just get the default."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
|
|
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
|
|
|
|
|
|
def _migration_3(conn) -> None:
|
|
"""Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a
|
|
three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS
|
|
(port 465 typically) is a different handshake entirely, not just a
|
|
skipped starttls() call, so it needs its own connection path in
|
|
app/mail.py."""
|
|
conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'"))
|
|
conn.execute(text(
|
|
"UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END"
|
|
))
|
|
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
|
|
|
|
|
|
def _migration_4(conn) -> None:
|
|
"""Advanced configuration: a per-frame color palette override. NULL
|
|
for every existing row -- exactly "use the default", no behavior
|
|
change until a frame's Configuration tab sets one."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
|
|
|
|
|
|
def _migration_5(conn) -> None:
|
|
"""Replaces the smart_crop_faces boolean with display_mode (see
|
|
image_pipeline.DISPLAY_MODES) -- crop_faces/crop_fill are exactly
|
|
the old True/False behavior, stretch_fill/letterbox are new."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN display_mode TEXT NOT NULL DEFAULT 'crop_faces'"))
|
|
conn.execute(text(
|
|
"UPDATE frames SET display_mode = CASE WHEN smart_crop_faces THEN 'crop_faces' ELSE 'crop_fill' END"
|
|
))
|
|
conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))
|
|
|
|
|
|
def _migration_6(conn) -> None:
|
|
"""Advanced configuration: color/contrast enhancement + dithering
|
|
strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
|
|
reproduce the exact previous rendering -- no behavior change until a
|
|
frame's Configuration tab adjusts one."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
|
|
|
|
|
|
def _migration_7(conn) -> None:
|
|
"""Calendar frame mode: a personal ICS subscription per user
|
|
(users.calendar_ics_url), an explicit per-(user,frame) opt-in into a
|
|
frame's merged calendar (user_frames.calendar_included, default off
|
|
-- linking to a frame does not auto-include your calendar there),
|
|
and the frame-level view/inlay/browse-offset/cache settings calendar
|
|
mode needs (see calendar_feed.py, calendar_render.py,
|
|
routers/device.py's RENDERERS["calendar"]). Every new column has a
|
|
behavior-preserving default -- no existing frame's behavior changes
|
|
until its mode is actually switched to "calendar"."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
|
|
|
|
|
|
def _migration_8(conn) -> None:
|
|
"""Configurable week-start day for calendar mode's week/month views
|
|
(0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
|
|
convention exactly -- no translation needed at render time). Default 0
|
|
(Monday) matches calendar_render.py's previous hardcoded behavior, so
|
|
this is a no-op for every existing frame until changed."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
|
|
def _migration_9(conn) -> None:
|
|
"""CalDAV support alongside the plain ICS subscription (see
|
|
caldav_client.py), and the frame_calendars table that replaces
|
|
user_frames.calendar_included now that one account (CalDAV) can
|
|
expose more than one calendar -- see models.py's FrameCalendar.
|
|
Existing single-calendar opt-ins are carried forward as "ics" rows
|
|
before the old column is dropped, so nobody's frame goes silently
|
|
calendar-less after this migration."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
|
|
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)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
|
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
|
|
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
|
|
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
|
|
))
|
|
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
|
|
|
|
|
|
def _migration_10(conn) -> None:
|
|
"""Optional weather strip for calendar mode (agenda/today & tomorrow/
|
|
week views -- never month, see calendar_render.py's _BUILDERS).
|
|
Multiple cities per frame (calendar_weather_cities), each geocoded
|
|
once via weather.py's Open-Meteo lookup (no API key) and their daily
|
|
forecasts refreshed on their own throttle, same shape idiom as
|
|
calendar_checked_at/calendar_cached_events. Off by default -- no
|
|
existing frame's render changes until its Calendar tab turns it on."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_enabled INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cities TEXT"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
|
|
|
|
|
|
def _migration_11(conn) -> None:
|
|
"""Manual per-calendar color choice (frame_calendars.color_index,
|
|
2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
|
|
Green). calendar_render.py's event color bar/dot used to auto-cycle
|
|
through those same four colors in whatever order calendars happened
|
|
to appear; this lets a household pin a specific one instead so it
|
|
stays stable and recognizable. NULL (the default) keeps the old
|
|
auto-cycle behavior -- no existing frame's render changes until
|
|
someone actually picks a color."""
|
|
conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))
|
|
|
|
|
|
def _migration_12(conn) -> None:
|
|
"""Week view flexibility: a configurable day count (2-10, default 7
|
|
-- the original fixed behavior) and a horizontal/vertical layout
|
|
choice, plus an optional CalDAV task list that takes the space of
|
|
one day slot when enabled (see calendar_render.py's _build_week/
|
|
_draw_tasks). Every new column has a behavior-preserving default --
|
|
no existing frame's render changes until its Calendar tab touches
|
|
one of these."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_days INTEGER NOT NULL DEFAULT 7"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_calendar_key TEXT"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT"))
|
|
|
|
|
|
def _migration_13(conn) -> None:
|
|
"""A day-count-relative start offset for the week view
|
|
(calendar_week_start_offset), used instead of calendar_week_start's
|
|
fixed-weekday anchor once the view isn't a literal 7-day week --
|
|
"start on the most recent Monday" stops meaning much for e.g. a
|
|
5-day view. Default 0 (starts today) is a behavior-preserving no-op
|
|
until someone changes the day count away from 7."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
def _migration_14(conn) -> None:
|
|
"""Whiteboard frame mode: generic WebDAV credentials per user
|
|
(webdav_username/password, plus webdav_reuse_caldav_creds as a
|
|
convenience when it's the same Nextcloud account as an already-
|
|
configured CalDAV one -- see models.py's User docstring), and the
|
|
frame-level whiteboard source (whiteboard_user_id/url) + rendered-
|
|
PNG cache (see webdav_client.py, whiteboard.py,
|
|
routers/device.py's RENDERERS["whiteboard"]). Every new column has a
|
|
behavior-preserving default -- no existing frame's render changes
|
|
until its mode is actually switched to "whiteboard"."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
|
|
|
|
|
|
def _migration_15(conn) -> None:
|
|
"""Optional starting folder for the whiteboard file-picker (see
|
|
models.py's User.webdav_base_url docstring) -- purely a browsing
|
|
convenience, never used for actual fetch/render."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''"))
|
|
|
|
|
|
def _migration_16(conn) -> None:
|
|
"""Widget system: a frame can now hold N independently placed/sized
|
|
widgets (photos/calendar/whiteboard) instead of exactly one mode-wide
|
|
renderer -- see models.py's Widget/PhotoWidgetConfig/
|
|
CalendarWidgetConfig/WhiteboardWidgetConfig/FrameButtonAction,
|
|
app/grid.py, app/widgets/.
|
|
|
|
This migration only creates the new (empty) tables -- it does NOT
|
|
backfill a widget per existing frame here. That backfill (reading
|
|
each frame's current mode/settings to build a widget that reproduces
|
|
its exact current display, including the calendar_photo_inlay ->
|
|
two-widgets special case) is real per-mode branching logic that's
|
|
much less error-prone written as typed ORM object construction than
|
|
as hand-written column-by-column SQL -- see _ensure_widgets_backfilled,
|
|
called unconditionally at the end of run_migrations() for both this
|
|
upgrade path AND the from-scratch _ensure_frame_one() path, so both
|
|
produce the same default-widget invariant from one place rather than
|
|
two separately-maintained ones. Every existing frame is briefly
|
|
widget-less between this migration and that call within the same
|
|
startup, not across restarts -- nothing reads these tables yet at
|
|
that point regardless."""
|
|
conn.execute(text(
|
|
"CREATE TABLE widgets ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"widget_type TEXT NOT NULL, "
|
|
"x INTEGER NOT NULL, "
|
|
"y INTEGER NOT NULL, "
|
|
"w INTEGER NOT NULL, "
|
|
"h INTEGER NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0)"
|
|
))
|
|
conn.execute(text("CREATE INDEX ix_widgets_frame ON widgets (frame_id)"))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE photo_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"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 '[]')"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE calendar_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_events TEXT, "
|
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"weather_cities TEXT, "
|
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"weather_cached TEXT, "
|
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
|
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"tasks_calendar_key TEXT, "
|
|
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"tasks_cached TEXT)"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE whiteboard_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"url TEXT NOT NULL DEFAULT '', "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_image BLOB)"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_button_actions ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"button TEXT NOT NULL, "
|
|
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"action TEXT NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_frame_button_actions_frame_button ON frame_button_actions (frame_id, button, sort_order)"
|
|
))
|
|
|
|
|
|
def _migration_17(conn) -> None:
|
|
"""Splits the calendar widget's old week-view-only task list out into
|
|
its own standalone widget type (see models.TaskWidgetConfig,
|
|
app/widgets/tasks.py) -- a task list is no longer tied to a
|
|
calendar's view or footprint, and can be placed/sized on its own.
|
|
|
|
Every calendar_widget_configs row that still has a task source
|
|
configured gets a new sibling `tasks` widget carrying that source
|
|
over, auto-placed in whatever open grid space is left on its frame
|
|
(same find_open_rect logic a manual "add widget" uses; if truly none
|
|
is left, the source is dropped and logged -- rare enough, and with
|
|
no interactive way to ask during a boot-time migration, that this is
|
|
an acceptable edge case). calendar_widget_configs then drops its now
|
|
-dead tasks_* columns -- this project's usual same-migration-drop
|
|
convention (see docs/widgets.md's Known Gaps for the one deliberate,
|
|
much-larger-blast-radius exception)."""
|
|
conn.execute(text(
|
|
"CREATE TABLE task_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"calendar_key TEXT, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached TEXT)"
|
|
))
|
|
|
|
rows = conn.execute(text(
|
|
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
|
|
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
|
|
"FROM calendar_widget_configs cwc "
|
|
"JOIN widgets w ON w.id = cwc.widget_id "
|
|
"JOIN frames f ON f.id = w.frame_id "
|
|
"WHERE cwc.tasks_calendar_key IS NOT NULL"
|
|
)).mappings().all()
|
|
|
|
skipped = 0
|
|
now = time.time()
|
|
for row in rows:
|
|
existing = conn.execute(text(
|
|
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
|
|
), {"frame_id": row["frame_id"]}).all()
|
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
|
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
|
|
if rect is None:
|
|
skipped += 1
|
|
continue
|
|
x, y, w, h = rect
|
|
max_sort = conn.execute(text(
|
|
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
|
), {"frame_id": row["frame_id"]}).scalar()
|
|
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) "
|
|
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)"
|
|
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
|
"sort_order": max_sort + 1, "created_at": now})
|
|
new_widget_id = result.lastrowid
|
|
conn.execute(text(
|
|
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
|
|
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
|
|
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
|
|
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
|
|
"cached": row["tasks_cached"]})
|
|
|
|
if skipped:
|
|
logger.warning(
|
|
"%d calendar widget(s) had a task list configured but no open grid space for a "
|
|
"standalone tasks widget -- their task source was dropped", skipped
|
|
)
|
|
|
|
# Rebuild calendar_widget_configs without the now-dead tasks_*
|
|
# columns -- SQLite can't drop tasks_user_id directly (it's part of
|
|
# an FK constraint), same situation frame_calendars hit in
|
|
# _migration_9, same rebuild-create-copy-drop-rename fix.
|
|
conn.execute(text(
|
|
"CREATE TABLE calendar_widget_configs_new ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_events TEXT, "
|
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"weather_cities TEXT, "
|
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"weather_cached TEXT, "
|
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
|
"week_start_offset INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO calendar_widget_configs_new "
|
|
"(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) "
|
|
"SELECT 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 "
|
|
"FROM calendar_widget_configs"
|
|
))
|
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
|
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
|
|
|
|
|
|
def _migration_18(conn) -> None:
|
|
"""A tasks widget can now merge more than one person's CalDAV task
|
|
list, checkbox-included with an optional pinned color each -- same
|
|
multi-source shape calendar widgets already have (models.
|
|
FrameCalendar), rather than the single user_id/calendar_key pair
|
|
migration 17 gave TaskWidgetConfig when tasks first became their own
|
|
widget type. Also adds show_completed (see caldav_client.
|
|
fetch_tasks' completed_since -- off by default, so this migration
|
|
changes no widget's on-panel appearance by itself).
|
|
|
|
Each task_widget_configs row's existing single source, if any,
|
|
carries forward as that widget's first frame_task_lists row
|
|
(included) before the now-dead user_id/calendar_key columns are
|
|
dropped -- same "carry forward the old single opt-in as a row before
|
|
dropping the column" shape _migration_9 used for frame_calendars."""
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_task_lists ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"widget_id INTEGER NOT NULL REFERENCES widgets(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_task_lists_unique ON frame_task_lists (widget_id, user_id, calendar_key)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
|
|
"SELECT widget_id, user_id, calendar_key, 1 FROM task_widget_configs "
|
|
"WHERE calendar_key IS NOT NULL AND user_id IS NOT NULL"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE task_widget_configs_new ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached TEXT, "
|
|
"show_completed INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO task_widget_configs_new (widget_id, checked_at, cached) "
|
|
"SELECT widget_id, checked_at, cached FROM task_widget_configs"
|
|
))
|
|
conn.execute(text("DROP TABLE task_widget_configs"))
|
|
conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs"))
|
|
|
|
|
|
def _migration_19(conn) -> None:
|
|
"""Optional custom on-panel name for a tasks widget (see
|
|
calendar_render._draw_tasks), replacing the default "Tasks" header
|
|
-- the only widget type with its own on-panel title at all, since
|
|
it's the only one where "which list is this" isn't already obvious
|
|
from its content. "" (the default) keeps the old hardcoded text, so
|
|
this changes no existing widget's appearance by itself. Plain
|
|
column add, no FK/index involved -- no rebuild-table dance needed
|
|
(unlike task_widget_configs' two previous migrations)."""
|
|
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN name TEXT NOT NULL DEFAULT ''"))
|
|
|
|
|
|
def _migration_20(conn) -> None:
|
|
"""New widget type: a static image widget shows whatever single
|
|
image (or a PDF's first page) the user last uploaded (see
|
|
app/image_upload.py, routers/api_widgets.py's api_widget_static_
|
|
upload) -- no live upstream to poll, unlike every other widget type.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all (this migration
|
|
originally used create_all -- switched retroactively once it turned
|
|
out to matter): create_all creates every table declared in Base.
|
|
metadata that's missing, not just this migration's own new one, so
|
|
it would just as happily create text_widget_configs (a LATER
|
|
migration's model, once TextWidgetConfig existed in models.py) years
|
|
before migration 21 gets a turn -- and then migration 21's own
|
|
CREATE TABLE collides with the one create_all already snuck in. Same
|
|
fix, same reasoning as migration 21's own comment about migration
|
|
22's ALTER TABLE -- see that one for the fuller explanation."""
|
|
conn.execute(text(
|
|
"CREATE TABLE static_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"image BLOB, "
|
|
"original_filename TEXT NOT NULL DEFAULT '', "
|
|
"uploaded_at REAL NOT NULL DEFAULT 0.0, "
|
|
"display_mode TEXT NOT NULL DEFAULT 'crop_fill')"
|
|
))
|
|
|
|
|
|
def _migration_21(conn) -> None:
|
|
"""New widget type: a text widget shows user-authored rich text (see
|
|
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
|
|
-- another no-live-upstream type like migration 20's static image.
|
|
|
|
Raw CREATE TABLE (not Base.metadata.create_all, unlike migration 20's
|
|
static_widget_configs) because migration 22 adds a column to this
|
|
same table right after -- create_all always reflects models.py's
|
|
CURRENT shape, so replaying the full migration chain on an old
|
|
database would have it already include that later column and
|
|
collide with migration 22's ALTER TABLE. Same reason migration 17's
|
|
task_widget_configs CREATE TABLE is raw SQL rather than create_all,
|
|
ahead of migration 19's ALTER TABLE ADD COLUMN name."""
|
|
conn.execute(text(
|
|
"CREATE TABLE text_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"content TEXT, "
|
|
"font_size INTEGER NOT NULL DEFAULT 28, "
|
|
"align TEXT NOT NULL DEFAULT 'left', "
|
|
"background_color TEXT NOT NULL DEFAULT '#ffffff')"
|
|
))
|
|
|
|
|
|
def _migration_22(conn) -> None:
|
|
"""Adds a font family choice to the text widget (app/widgets/text.py's
|
|
FONT_FAMILIES) alongside its existing font_size -- both whole-widget
|
|
settings, not per-run. "sans" (Noto Sans) matches the column default
|
|
so existing text widgets keep rendering in the same font they always
|
|
have."""
|
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN font_family TEXT NOT NULL DEFAULT 'sans'"))
|
|
|
|
|
|
def _migration_23(conn) -> None:
|
|
"""New feature: named, user-owned saved layouts (see models.
|
|
SavedLayout/SavedLayoutWidget/SavedLayoutSource/
|
|
SavedLayoutButtonAction, routers/api_layouts.py) -- a snapshot of a
|
|
frame's widget arrangement a user can capture and later apply to any
|
|
frame they control whose grid matches, instead of manually rebuilding
|
|
it widget by widget.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
|
migration 20/21's own comments: create_all always reflects models.py's
|
|
CURRENT shape, so replaying the full chain on an old database could
|
|
collide with a later migration's ALTER TABLE on one of these same
|
|
tables."""
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layouts ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"name TEXT NOT NULL, "
|
|
"cols INTEGER NOT NULL, "
|
|
"rows INTEGER NOT NULL, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0, "
|
|
"updated_at REAL NOT NULL DEFAULT 0.0)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_saved_layouts_user_name ON saved_layouts (user_id, name)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layout_widgets ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"saved_layout_id INTEGER NOT NULL REFERENCES saved_layouts(id) ON DELETE CASCADE, "
|
|
"widget_type TEXT NOT NULL, "
|
|
"x INTEGER NOT NULL, y INTEGER NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
|
"config TEXT NOT NULL DEFAULT '{}', "
|
|
"image BLOB)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_saved_layout_widgets_layout ON saved_layout_widgets (saved_layout_id)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layout_sources ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
|
"kind TEXT NOT NULL, "
|
|
"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 INDEX ix_saved_layout_sources_widget ON saved_layout_sources (saved_layout_widget_id)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layout_button_actions ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
|
"button TEXT NOT NULL, "
|
|
"action TEXT NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_saved_layout_button_actions_widget ON saved_layout_button_actions (saved_layout_widget_id)"
|
|
))
|
|
|
|
|
|
def _migration_24(conn) -> None:
|
|
"""New widget type: standalone weather (current/hourly/daily/
|
|
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
|
|
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
|
|
Lifts the calendar widget's embedded weather strip's underlying
|
|
fetch/render building blocks (app/weather/open_meteo.py, the icon-
|
|
drawing primitives now in app/weather_render.py) out into a widget
|
|
that can be placed/sized on its own -- CalendarWidgetConfig's own
|
|
weather_* columns are untouched, still working exactly as before.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
|
migration 20/21/23's own comments: create_all always reflects
|
|
models.py's CURRENT shape, so replaying the full chain on an old
|
|
database could collide with a later migration's ALTER TABLE on this
|
|
same table."""
|
|
conn.execute(text(
|
|
"CREATE TABLE weather_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"mode TEXT NOT NULL DEFAULT 'current', "
|
|
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
|
|
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"city_label TEXT, "
|
|
"city_latitude REAL, "
|
|
"city_longitude REAL, "
|
|
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
|
|
"daily_days INTEGER NOT NULL DEFAULT 5, "
|
|
"cities TEXT, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached TEXT)"
|
|
))
|
|
|
|
|
|
def _migration_25(conn) -> None:
|
|
"""New widget type: battery (see models.BatteryWidgetConfig,
|
|
app/widgets/battery.py) -- shows the frame's own last-reported
|
|
battery level. No live upstream to poll and nothing to cache: unlike
|
|
every other widget type added since migration 20, the content is
|
|
frame-level state (Frame.battery_percent/battery_as_of) that already
|
|
existed before this widget did, so the only new column is a display
|
|
mode.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
|
migration 20/21/23/24's own comments: create_all always reflects
|
|
models.py's CURRENT shape, so replaying the full chain on an old
|
|
database could collide with a later migration's ALTER TABLE on this
|
|
same table."""
|
|
conn.execute(text(
|
|
"CREATE TABLE battery_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"mode TEXT NOT NULL DEFAULT 'detailed')"
|
|
))
|
|
|
|
|
|
def _migration_26(conn) -> None:
|
|
"""Per-widget border (see models.Widget.border_style/border_thickness/
|
|
border_color_index, image_pipeline.draw_widget_border) -- a shared
|
|
property on the widgets table itself, not a per-type config table,
|
|
since every widget type can have one regardless of widget_type.
|
|
border_style defaults to 'none' so existing widgets keep rendering
|
|
exactly as before until someone opts in via a widget's dialog.
|
|
|
|
Guarded per-column (unlike every earlier ALTER TABLE ADD COLUMN
|
|
migration in this file) because widgets is the one table
|
|
test_migrations.py's upgrade-path tests deliberately leave un-dropped
|
|
across a simulated old-schema_version replay (see those tests' own
|
|
comments: it hasn't changed shape since migration 16 created it, so
|
|
reusing the fresh-install create_all() copy -- which, unlike this
|
|
ALTER, already reflects models.py's current border_* columns -- was
|
|
safe up to now). Without the guard, replaying this migration in that
|
|
scenario re-adds a column that's already there and SQLite raises
|
|
"duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
|
|
if "border_style" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_style TEXT NOT NULL DEFAULT 'none'"))
|
|
if "border_thickness" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_thickness INTEGER NOT NULL DEFAULT 3"))
|
|
if "border_color_index" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
def _migration_27(conn) -> None:
|
|
"""Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes
|
|
current_asset_id against both the timer-elapsed auto-advance
|
|
(photo_queue.get_current) and the advance/back button actions
|
|
(app/widgets/photos.py's ACTIONS) until unlocked. Defaults to
|
|
unlocked so existing widgets keep rotating exactly as before.
|
|
|
|
Guarded per-column, same reasoning as migration 26's own comment:
|
|
photo_widget_configs isn't touched by test_migrations.py's simulated
|
|
pre-widget-system replays (unlike calendar/task/widgets tables those
|
|
tests DROP and recreate in an old shape), so it keeps the fresh-
|
|
install create_all() copy -- which already has this column -- when
|
|
those tests replay migrations 17+ from schema_version 16. Without
|
|
the guard, replaying this migration there re-adds a column that's
|
|
already there and SQLite raises "duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")}
|
|
if "locked" not in existing:
|
|
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
def _migration_28(conn) -> None:
|
|
"""One action per (widget, button) instead of an ordered per-button
|
|
list -- button-action editing moved from the frame-level "Button
|
|
assignments" card into each widget's own config dialog (see
|
|
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
|
|
api_widget_config_save). Cross-widget execution order never actually
|
|
mattered (each widget's action only touches its own state), so this
|
|
only needs to de-dupe down to one row before the new unique index can
|
|
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
|
|
but deterministically, since which specific extra binding a user's
|
|
old list happened to have doesn't matter anymore."""
|
|
conn.execute(text(
|
|
"DELETE FROM frame_button_actions WHERE id NOT IN "
|
|
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
|
|
"ON frame_button_actions (widget_id, button)"
|
|
))
|
|
|
|
|
|
def _migration_29(conn) -> None:
|
|
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
|
|
BACK past hold_duration_ms triggers a frame-wide action instead of
|
|
the per-widget one a short press runs. next_hold_action/
|
|
back_hold_action are NULL (disabled) by default -- existing frames
|
|
get no new button behavior until someone opts in on the
|
|
Configuration tab. last_cycled_layout_id tracks where a repeated
|
|
"cycle saved layouts" hold should resume from.
|
|
|
|
Guarded per-column, same reasoning as migration 26/27's own
|
|
comments: frames is a table test_migrations.py's pre-widget-system
|
|
replay tests leave un-dropped (unlike calendar/task/widget tables
|
|
those tests DROP and recreate in an old shape), so it keeps the
|
|
fresh-install create_all() copy -- which already has these columns
|
|
-- when those tests replay migrations 17+ from schema_version 16.
|
|
Without the guard, replaying this migration there re-adds a column
|
|
that's already there and SQLite raises "duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "hold_duration_ms" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
|
|
if "next_hold_action" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
|
|
if "back_hold_action" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
|
|
if "last_cycled_layout_id" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
|
|
|
|
|
def _migration_30(conn) -> None:
|
|
""""Now displaying" (models.Frame.last_displayed_image/
|
|
last_displayed_at) -- the web UI's header preview pair needs a frozen
|
|
record of exactly what the last device-facing render actually sent,
|
|
separate from the always-live "up next" re-render (see
|
|
routers/device.py's _record_last_displayed, api_frames.py's
|
|
/now-displaying endpoint). NULL/0.0 for every existing frame until
|
|
its next real device fetch -- no behavior change to what's served,
|
|
only a new thing recorded alongside it.
|
|
|
|
Guarded per-column, same reasoning as migration 26/27/29's own
|
|
comments: frames is a table test_migrations.py's pre-widget-system
|
|
replay tests leave un-dropped, so it keeps the fresh-install
|
|
create_all() copy -- which already has these columns -- when those
|
|
tests replay migrations 17+ from schema_version 16. Without the
|
|
guard, replaying this migration there re-adds a column that's already
|
|
there and SQLite raises "duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "last_displayed_image" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
|
|
if "last_displayed_at" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
|
|
|
|
|
def _migration_31(conn) -> None:
|
|
"""Weather widget render style (models.WeatherWidgetConfig.
|
|
render_style): "classic" (existing hand-drawn PIL renderer,
|
|
unchanged) or "modern" (app/html_render.py's headless-Chromium/CSS
|
|
renderer). Every existing weather widget defaults to "classic" --
|
|
no behavior change until a widget's dialog switches it.
|
|
|
|
Guarded per-column, same reasoning as migration 30's own comment:
|
|
weather_widget_configs is a table some replay tests may re-create
|
|
fresh via create_all() (which already has this column) rather than
|
|
replaying migration 24's raw CREATE TABLE."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("weather_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_32(conn) -> None:
|
|
"""Photos widget's own independent palette/dithering (models.Frame.
|
|
photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's
|
|
render()). NULL/1.0 defaults reproduce the exact previous rendering
|
|
(same reference palette/strength as the main fields) until a frame's
|
|
Configuration tab sets them differently.
|
|
|
|
Guarded per-column, same reasoning as migration 30/31's own comments."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "photo_palette_rgb" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT"))
|
|
if "photo_dither_strength" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0"))
|
|
|
|
|
|
def _migration_33(conn) -> None:
|
|
"""Battery widget render style (models.BatteryWidgetConfig.
|
|
render_style) -- same shape as migration 31's weather one. Every
|
|
existing battery widget defaults to "classic", no behavior change."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_34(conn) -> None:
|
|
"""Text widget render style (models.TextWidgetConfig.render_style) --
|
|
same shape as migration 31/33. Every existing text widget defaults to
|
|
"classic", no behavior change."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_35(conn) -> None:
|
|
"""Tasks widget render style (models.TaskWidgetConfig.render_style)
|
|
-- same shape as migration 31/33/34. Every existing tasks widget
|
|
defaults to "classic", no behavior change."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_36(conn) -> None:
|
|
"""Static image widget render style (models.StaticWidgetConfig.
|
|
render_style) -- same shape as migration 31/33/34/35."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_37(conn) -> None:
|
|
"""Whiteboard widget render style (models.WhiteboardWidgetConfig.
|
|
render_style) -- same shape as migration 36."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_38(conn) -> None:
|
|
"""Calendar widget render style (models.CalendarWidgetConfig.
|
|
render_style) -- same shape as migration 31/33/34/35/36/37."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_39(conn) -> None:
|
|
"""Frame-level curated theme for "modern" style widgets (models.
|
|
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
|
|
shape as every prior migration."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "theme" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_40(conn) -> None:
|
|
"""Per-widget text-size multiplier (models.Widget.font_scale, see
|
|
panel_style.FONT_SCALE_CHOICES) -- a Widget-level column like
|
|
border_style/border_thickness/border_color_index, not a per-type
|
|
config field, since any widget type with body text can use it. Every
|
|
existing widget defaults to 1.0 (unchanged size) until its own
|
|
dialog's "Text size" picker sets it. Guarded per-column, same
|
|
reasoning as every prior migration's own comment."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
|
|
if "font_scale" not in existing:
|
|
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),
|
|
(3, _migration_3),
|
|
(4, _migration_4),
|
|
(5, _migration_5),
|
|
(6, _migration_6),
|
|
(7, _migration_7),
|
|
(8, _migration_8),
|
|
(9, _migration_9),
|
|
(10, _migration_10),
|
|
(11, _migration_11),
|
|
(12, _migration_12),
|
|
(13, _migration_13),
|
|
(14, _migration_14),
|
|
(15, _migration_15),
|
|
(16, _migration_16),
|
|
(17, _migration_17),
|
|
(18, _migration_18),
|
|
(19, _migration_19),
|
|
(20, _migration_20),
|
|
(21, _migration_21),
|
|
(22, _migration_22),
|
|
(23, _migration_23),
|
|
(24, _migration_24),
|
|
(25, _migration_25),
|
|
(26, _migration_26),
|
|
(27, _migration_27),
|
|
(28, _migration_28),
|
|
(29, _migration_29),
|
|
(30, _migration_30),
|
|
(31, _migration_31),
|
|
(32, _migration_32),
|
|
(33, _migration_33),
|
|
(34, _migration_34),
|
|
(35, _migration_35),
|
|
(36, _migration_36),
|
|
(37, _migration_37),
|
|
(38, _migration_38),
|
|
(39, _migration_39),
|
|
(40, _migration_40),
|
|
(41, _migration_41),
|
|
]
|
|
|
|
|
|
def run_migrations() -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
|
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
|
if row is None:
|
|
# Brand new database: _migration_1's create_all() already
|
|
# produces today's full schema straight from models.py.
|
|
# Every migration after it is an incremental ALTER/UPDATE
|
|
# meant to bring an *existing* install forward -- replaying
|
|
# those here would just collide with columns create_all
|
|
# already added (e.g. "duplicate column name"). Jump
|
|
# straight to the latest version instead.
|
|
_migration_1(conn)
|
|
current = MIGRATIONS[-1][0]
|
|
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
|
|
else:
|
|
current = row[0]
|
|
|
|
# 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_frame_calendars_rekeyed()
|
|
|
|
|
|
def new_device_token() -> str:
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def new_manage_token() -> str:
|
|
return secrets.token_urlsafe(16)
|
|
|
|
|
|
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 -- 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
|
|
|
|
cfg = config.load() # all defaults if the file doesn't exist
|
|
had_file = config.CONFIG_PATH.exists()
|
|
|
|
frame = Frame(
|
|
name="Frame 1",
|
|
device_id=None,
|
|
device_token=new_device_token(),
|
|
manage_token=new_manage_token(),
|
|
created_at=time.time(),
|
|
immich_url=cfg.immich_url,
|
|
immich_api_key=cfg.immich_api_key,
|
|
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,
|
|
orientation=cfg.orientation,
|
|
battery_percent=cfg.battery_percent,
|
|
battery_as_of=cfg.battery_as_of,
|
|
battery_history=[list(pair) for pair in cfg.battery_history],
|
|
last_seen=cfg.last_seen,
|
|
device_firmware_version=cfg.device_firmware_version,
|
|
device_board_variant=cfg.device_board_variant,
|
|
firmware_available_version=cfg.firmware_available_version,
|
|
firmware_update_repo_url=cfg.firmware_update_repo_url,
|
|
firmware_auto_update=cfg.firmware_auto_update,
|
|
firmware_update_token=cfg.firmware_update_token,
|
|
firmware_update_checked_at=cfg.firmware_update_checked_at,
|
|
firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
|
|
stats_first_seen=cfg.stats.first_seen,
|
|
stats_device_wakes=cfg.stats.device_wakes,
|
|
stats_photos_displayed=cfg.stats.photos_displayed,
|
|
stats_photos_removed=cfg.stats.photos_removed,
|
|
stats_battery_reports=cfg.stats.battery_reports,
|
|
stats_recharge_cycles=cfg.stats.recharge_cycles,
|
|
stats_ota_updates_applied=cfg.stats.ota_updates_applied,
|
|
stats_config_saves=cfg.stats.config_saves,
|
|
)
|
|
db.add(frame)
|
|
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.
|
|
legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
|
|
if legacy_bin.exists():
|
|
per_frame_dir = config.CONFIG_PATH.parent / "firmware"
|
|
per_frame_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")
|
|
|
|
if had_file:
|
|
logger.info(
|
|
"Imported legacy config.json as frame #%d (%d battery log entries)",
|
|
frame.id,
|
|
len(cfg.battery_log),
|
|
)
|
|
else:
|
|
logger.info("Fresh install: created default frame #%d", frame.id)
|
|
|
|
|
|
def _ensure_server_settings() -> None:
|
|
"""The SMTP config singleton (id=1) -- created with everything blank
|
|
(email sending disabled) the first time this runs; /admin edits it in
|
|
place from then on."""
|
|
with SessionLocal() as db:
|
|
if db.get(ServerSettings, 1) is None:
|
|
db.add(ServerSettings(id=1))
|
|
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
|
|
system), each with its own included-calendars set, so "included on
|
|
this frame" no longer means anything unambiguous (see
|
|
models.FrameCalendar). Existing rows attach to their frame's calendar
|
|
widget if it has one; rows for a frame with no calendar widget at all
|
|
are dropped -- they were already-dormant settings for content
|
|
nothing ever actually displayed (the Calendar tab stayed reachable
|
|
and savable even while a frame's old `mode` was "photos"), not real
|
|
live configuration.
|
|
|
|
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 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:
|
|
return
|
|
with engine.begin() as conn:
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars_new ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"widget_id INTEGER NOT NULL REFERENCES widgets(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(
|
|
"INSERT INTO frame_calendars_new (widget_id, user_id, calendar_key, calendar_label, included, color_index) "
|
|
"SELECT w.id, fc.user_id, fc.calendar_key, fc.calendar_label, fc.included, fc.color_index "
|
|
"FROM frame_calendars fc "
|
|
"JOIN widgets w ON w.frame_id = fc.frame_id AND w.widget_type = 'calendar'"
|
|
))
|
|
conn.execute(text("DROP TABLE frame_calendars"))
|
|
conn.execute(text("ALTER TABLE frame_calendars_new RENAME TO frame_calendars"))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (widget_id, user_id, calendar_key)"
|
|
))
|