Shows Frame.battery_percent/battery_as_of, already set by every device wake-on-battery report, plus routers/common.py's existing battery_estimate_s time-remaining estimate -- nothing new to fetch or cache. Compact (icon + percent) or detailed (+ estimate, last report age) display mode. No button actions.
1082 lines
52 KiB
Python
1082 lines
52 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,
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
ServerSettings,
|
|
TaskWidgetConfig,
|
|
WhiteboardWidgetConfig,
|
|
Widget,
|
|
)
|
|
|
|
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) "
|
|
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
|
|
), {"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')"
|
|
))
|
|
|
|
|
|
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),
|
|
]
|
|
|
|
|
|
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)
|
|
latest = MIGRATIONS[-1][0]
|
|
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
|
|
else:
|
|
current = row[0]
|
|
for version, fn in MIGRATIONS:
|
|
if version > current:
|
|
logger.info("Applying schema migration %d", version)
|
|
fn(conn)
|
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
|
_ensure_frame_one()
|
|
_ensure_server_settings()
|
|
_ensure_widgets_backfilled()
|
|
_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. Either way it's the legacy-token frame: the deployed
|
|
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
|
|
and require_device resolves those requests here. The frames-nonempty
|
|
guard makes this idempotent; config.json is left untouched as the
|
|
rollback path."""
|
|
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(),
|
|
legacy_token_enabled=True,
|
|
created_at=time.time(),
|
|
immich_url=cfg.immich_url,
|
|
immich_api_key=cfg.immich_api_key,
|
|
album_id=cfg.album_id,
|
|
order=cfg.order,
|
|
refresh_interval_s=cfg.refresh_interval_s,
|
|
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
|
quiet_hours_start=cfg.quiet_hours_start,
|
|
quiet_hours_end=cfg.quiet_hours_end,
|
|
timezone=cfg.timezone,
|
|
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
|
orientation=cfg.orientation,
|
|
queue_target_len=cfg.queue_target_len,
|
|
current_asset_id=cfg.current_asset_id,
|
|
current_asset_set_at=cfg.current_asset_set_at,
|
|
queue=list(cfg.queue),
|
|
queue_cursor=cfg.queue_cursor,
|
|
history=list(cfg.history),
|
|
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
|
battery_percent=cfg.battery_percent,
|
|
battery_as_of=cfg.battery_as_of,
|
|
battery_history=[list(pair) for pair in cfg.battery_history],
|
|
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
|
|
|
|
for pair in cfg.battery_log:
|
|
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
|
|
|
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 _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
|
|
return PhotoWidgetConfig(
|
|
widget_id=widget_id,
|
|
album_id=frame.album_id,
|
|
order=frame.order,
|
|
display_mode=frame.display_mode,
|
|
queue_target_len=frame.queue_target_len,
|
|
current_asset_id=frame.current_asset_id,
|
|
current_asset_set_at=frame.current_asset_set_at,
|
|
queue=list(frame.queue),
|
|
queue_cursor=frame.queue_cursor,
|
|
history=list(frame.history),
|
|
excluded_asset_ids=list(frame.excluded_asset_ids),
|
|
)
|
|
|
|
|
|
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
|
|
return CalendarWidgetConfig(
|
|
widget_id=widget_id,
|
|
view=frame.calendar_view,
|
|
week_start=frame.calendar_week_start,
|
|
browse_offset=frame.calendar_browse_offset,
|
|
checked_at=frame.calendar_checked_at,
|
|
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
|
|
fetch_summary=frame.calendar_fetch_summary,
|
|
weather_enabled=frame.calendar_weather_enabled,
|
|
weather_units=frame.calendar_weather_units,
|
|
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
|
|
weather_checked_at=frame.calendar_weather_checked_at,
|
|
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
|
|
week_days=frame.calendar_week_days,
|
|
week_layout=frame.calendar_week_layout,
|
|
week_start_offset=frame.calendar_week_start_offset,
|
|
# tasks_* deliberately not carried over -- see
|
|
# _task_config_and_list_from_frame, a sibling standalone widget
|
|
# now, not part of this config.
|
|
)
|
|
|
|
|
|
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
|
|
"""Only ever called for a frame whose legacy calendar_tasks_* columns
|
|
(see Frame's own docstring on those -- a dead pre-widget-system
|
|
field set, same status as calendar_photo_inlay below) still carry a
|
|
configured source -- i.e. a database jumping straight from before
|
|
the widget system existed to after tasks became their own
|
|
multi-list widget type in a single upgrade, skipping both
|
|
intermediate periods where it would have lived on
|
|
CalendarWidgetConfig (_migration_17's extraction) and then a
|
|
single-source TaskWidgetConfig (_migration_18's extraction) instead.
|
|
Reproduces the same shape those two migrations arrive at directly:
|
|
a bare cache-state config plus one included FrameTaskList row."""
|
|
cfg = TaskWidgetConfig(
|
|
widget_id=widget_id,
|
|
checked_at=frame.calendar_tasks_checked_at,
|
|
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
|
)
|
|
task_list = FrameTaskList(
|
|
widget_id=widget_id,
|
|
user_id=frame.calendar_tasks_user_id,
|
|
calendar_key=frame.calendar_tasks_calendar_key,
|
|
included=True,
|
|
)
|
|
return cfg, task_list
|
|
|
|
|
|
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
|
|
return WhiteboardWidgetConfig(
|
|
widget_id=widget_id,
|
|
user_id=frame.whiteboard_user_id,
|
|
url=frame.whiteboard_url,
|
|
checked_at=frame.whiteboard_checked_at,
|
|
cached_image=frame.whiteboard_cached_image,
|
|
)
|
|
|
|
|
|
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
|
|
"""NEXT/BACK -> whatever this widget's own advance/back concept is
|
|
(see app/widgets/ for the actual action registry, built in a later
|
|
phase) -- reproduces each mode's exact old button behavior for the
|
|
one auto-migrated widget, so upgrading changes nothing about what the
|
|
physical buttons do until someone deliberately reassigns them."""
|
|
if widget_type == "whiteboard":
|
|
# No real "next"/"back" concept for a static board -- both
|
|
# buttons already meant "check now" before this migration (see
|
|
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
|
|
return [
|
|
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
|
|
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
|
|
]
|
|
return [
|
|
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
|
|
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
|
|
]
|
|
|
|
|
|
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
|
"""Only relevant for a database jumping straight from before the
|
|
widget system existed to after tasks became their own widget type
|
|
in one upgrade (see _task_config_and_list_from_frame) --
|
|
frame.calendar_tasks_* is the dead legacy field set otherwise.
|
|
Requires both calendar_key and user_id (FrameTaskList.user_id is
|
|
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
|
|
Auto-placed in whatever open space is left after the widget(s) above
|
|
it in _backfill_frame_widgets claimed theirs, same find_open_rect
|
|
logic a manual "add widget" uses; silently dropped (logged) if none
|
|
fits, same as this migration having nowhere else to put it either."""
|
|
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
|
|
return
|
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
|
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
|
|
if rect is None:
|
|
logger.warning(
|
|
"Frame %d had a legacy task list configured but no open grid space for a "
|
|
"standalone tasks widget during backfill -- its task source was dropped", frame.id
|
|
)
|
|
return
|
|
x, y, w, h = rect
|
|
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
|
sort_order=next_sort_order, created_at=time.time())
|
|
db.add(task_widget)
|
|
db.flush()
|
|
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
|
|
db.add(cfg)
|
|
db.add(task_list)
|
|
|
|
|
|
def _backfill_frame_widgets(db, frame: Frame) -> None:
|
|
cols, rows = grid.grid_dims(frame.orientation)
|
|
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
|
|
|
|
if mode == "calendar" and frame.calendar_photo_inlay:
|
|
# Reproduces the old fixed 50/50 inlay split as two independent
|
|
# widgets instead of silently dropping half of what the frame was
|
|
# showing -- see models.py's CalendarWidgetConfig docstring on why
|
|
# "photo inlay" isn't a widget-system concept anymore otherwise.
|
|
half = cols // 2
|
|
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
|
|
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
|
|
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
|
|
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
|
|
db.add_all([cal_widget, photo_widget])
|
|
db.flush() # assign ids before the FK'd config rows reference them
|
|
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
|
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
|
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
|
_maybe_add_legacy_tasks_widget(
|
|
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
|
)
|
|
return
|
|
|
|
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
|
sort_order=0, created_at=time.time())
|
|
db.add(widget)
|
|
db.flush()
|
|
if mode == "photos":
|
|
db.add(_photo_config_from_frame(frame, widget.id))
|
|
elif mode == "calendar":
|
|
db.add(_calendar_config_from_frame(frame, widget.id))
|
|
elif mode == "whiteboard":
|
|
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
|
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
|
if mode == "calendar":
|
|
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
|
|
|
|
|
def _ensure_widgets_backfilled() -> None:
|
|
"""Every frame needs at least one Widget once the widget system is
|
|
live -- runs unconditionally after every startup (both a from-scratch
|
|
_ensure_frame_one() install and an existing-install upgrade past
|
|
_migration_16 land here) and is a no-op for any frame that already
|
|
has one. Builds a widget that reproduces the frame's current mode/
|
|
settings/state exactly, so upgrading never changes what a frame
|
|
displays or what its physical buttons do on its own."""
|
|
with SessionLocal() as db:
|
|
for frame in db.scalars(select(Frame)).all():
|
|
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
|
|
if has_widget is not None:
|
|
continue
|
|
_backfill_frame_widgets(db, frame)
|
|
db.commit()
|
|
|
|
|
|
def _ensure_frame_calendars_rekeyed() -> None:
|
|
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
|
|
hold more than one independent calendar widget (see the widget
|
|
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: this needs each frame's
|
|
calendar widget to already exist to know what to re-key against, and
|
|
those widget rows aren't created by a schema migration at all --
|
|
they come from _ensure_widgets_backfilled() above, which (like this
|
|
function) runs unconditionally after every startup rather than being
|
|
tracked by schema_version. Running this as a numbered migration
|
|
would execute it *before* that backfill during a real upgrade (the
|
|
numbered-migration loop runs first, see run_migrations), silently
|
|
dropping every row -- caught by test_migrations.py actually exercising
|
|
the raw-SQL upgrade path instead of the fresh-install create_all()
|
|
shortcut every other test in that file takes.
|
|
|
|
Runs unconditionally after every startup, like _ensure_widgets_
|
|
backfilled; a no-op the moment frame_calendars is already
|
|
widget_id-shaped (every fresh install, and any existing install
|
|
after its first run past this code) -- SQLite can't ALTER a column's
|
|
FK target or drop a column that's part of an index/FK constraint, so
|
|
when it isn't a no-op this is the standard SQLite "rebuild" pattern:
|
|
create the new-shape table, copy matching rows across (joining to
|
|
find each row's calendar widget), drop the old table, rename the new
|
|
one into place."""
|
|
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)"
|
|
))
|