Files
espresso_frame/server/app/migration.py
T
tfaour db9a6f1875
Build and push server image / build-and-push (push) Successful in 53s
Week view: relative start-day offset for non-7-day counts; hide week-only settings elsewhere
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.

Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
2026-07-23 08:17:25 -04:00

347 lines
17 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 select, text
from . import config
from .db import SessionLocal, engine
from .models import Base, BatteryLog, Frame, ServerSettings
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"))
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),
]
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()
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()