New columns, all ADD COLUMN with inert defaults -- no existing frame's behavior changes until mode is explicitly switched to "calendar": - users.calendar_ics_url: one personal iCal/CalDAV subscription per user, same shape as the existing per-user immich_url/immich_api_key. - user_frames.calendar_included: explicit per-(user,frame) opt-in, default off. Being linked to a frame does not by itself contribute your calendar to it -- each person's calendar is their own data to share, not something a frame's controller decides on their behalf. - frames.calendar_view/calendar_photo_inlay/calendar_browse_offset: per-frame display settings and NEXT/BACK navigation state. - frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary: the throttled merge-fetch cache, same shape as the existing firmware_update_checked_at/firmware_gitea_latest_version pattern.
242 lines
11 KiB
Python
242 lines
11 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 ''"))
|
|
|
|
|
|
MIGRATIONS = [
|
|
(1, _migration_1),
|
|
(2, _migration_2),
|
|
(3, _migration_3),
|
|
(4, _migration_4),
|
|
(5, _migration_5),
|
|
(6, _migration_6),
|
|
(7, _migration_7),
|
|
]
|
|
|
|
|
|
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()
|