Build and push server image / build-and-push (push) Successful in 53s
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.
347 lines
17 KiB
Python
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()
|