Files
espresso_frame/server/app/migration.py
T
tfaour 474b92a282
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
2026-08-04 20:08:22 +00:00

1451 lines
73 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"))
def _migration_42(conn) -> None:
"""Which EPD panel a frame renders for (models.Frame.panel_type, see
image_pipeline.PANEL_SPECS) -- same guarded-per-column shape as every
prior migration. Every existing frame defaults to 'epd7in3e' (the
original 7.3" panel), auto-corrected on next check-in if the device
actually reports a different board (routers/device.py's
BOARD_PANEL_MAP)."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "panel_type" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN panel_type TEXT NOT NULL DEFAULT 'epd7in3e'"))
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),
(42, _migration_42),
]
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)"
))