"""SQLAlchemy models: users, sessions, frames, links, claims, battery log. One deliberately WIDE `frames` row per frame (settings + state + telemetry + stats together): every device request touches exactly one row, so the per-frame lock in db.frame_locked() keeps the old whole-config-lock semantics trivially correct, and SQLite doesn't care about row width. The queue/history/excluded/battery_history columns are MutableList-mapped JSON: photo_queue.py mutates them in place (pop/append/insert), which a plain JSON column would silently not persist -- MutableList marks the row dirty on in-place changes. The ORM attribute for the photo ordering setting is `order` (matching the old FrameConfig field name so photo_queue.py ports unchanged) but the column is named photo_order to stay clear of the SQL keyword. """ from __future__ import annotations import time from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String from sqlalchemy.ext.mutable import MutableList from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) # Normalized to lowercase in code before insert/lookup -- portable # case-insensitive uniqueness without SQLite-only COLLATE NOCASE. username: Mapped[str] = mapped_column(String, unique=True) display_name: Mapped[str] = mapped_column(String, default="") # Pluggable identity: "local" now; an OIDC provider later would set # provider_subject and leave password_hash NULL. identity_provider: Mapped[str] = mapped_column(String, default="local") provider_subject: Mapped[str] = mapped_column(String, default="") password_hash: Mapped[str | None] = mapped_column(String, nullable=True) is_admin: Mapped[bool] = mapped_column(Boolean, default=False) immich_url: Mapped[str] = mapped_column(String, default="") immich_api_key: Mapped[str] = mapped_column(String, default="") # Password-reset emails and battery-threshold alerts (frames.owner's # email -- see routers/device.py's frame_battery) go here; blank = no # email configured, both features silently no-op for this user. email: Mapped[str] = mapped_column(String, default="") created_at: Mapped[float] = mapped_column(Float, default=time.time) __table_args__ = ( Index( "ix_users_provider_subject", "identity_provider", "provider_subject", unique=True, sqlite_where=provider_subject != "", ), ) class UserSession(Base): __tablename__ = "sessions" id: Mapped[int] = mapped_column(primary_key=True) token_hash: Mapped[str] = mapped_column(String, unique=True) # sha256 hex of cookie value user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) csrf_token: Mapped[str] = mapped_column(String) created_at: Mapped[float] = mapped_column(Float, default=time.time) expires_at: Mapped[float] = mapped_column(Float, index=True) user: Mapped[User] = relationship() class Frame(Base): __tablename__ = "frames" id: Mapped[int] = mapped_column(primary_key=True) # 12 lowercase hex chars of the device's full STA MAC. NULL only for # the migrated legacy frame until its device first reports an id. device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) name: Mapped[str] = mapped_column(String, default="") # Renderer dispatch seam for future calendar/canva modes -- only # "photos" is registered today (see routers/device.py RENDERERS). mode: Mapped[str] = mapped_column(String, default="photos") # Whose Immich library this frame pulls from; NULL = unclaimed. owner_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) # "Take control" soft lock -- only this user may mutate settings/queue. controlled_by_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) device_token: Mapped[str] = mapped_column(String) # Device has authenticated with device_token at least once -- stop # pushing it in /frame/config responses. device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False) manage_token: Mapped[str] = mapped_column(String, unique=True) # Migration window: this frame also accepts the legacy shared # MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the # migrated frame #1; cleared from /admin once the device is on # per-frame auth. legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False) claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[float] = mapped_column(Float, default=time.time) # Migration staging only: Immich creds imported from the legacy # config.json/env live here until /setup copies them to admin #1. # Runtime resolution prefers owner creds, then env, then these (see # routers/common.py immich_creds()). immich_url: Mapped[str] = mapped_column(String, default="") immich_api_key: Mapped[str] = mapped_column(String, default="") # -- settings (attribute names match the old FrameConfig fields) -- album_id: Mapped[str] = mapped_column(String, default="") order: Mapped[str] = mapped_column("photo_order", String, default="sequential") refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600) quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False) quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00") quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00") timezone: Mapped[str] = mapped_column(String, default="UTC") smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True) orientation: Mapped[str] = mapped_column(String, default="landscape") queue_target_len: Mapped[int] = mapped_column(Integer, default=20) # -- state -- current_asset_id: Mapped[str] = mapped_column(String, default="") current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0) queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) queue_cursor: Mapped[int] = mapped_column(Integer, default=0) history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) # -- telemetry -- battery_percent: Mapped[int] = mapped_column(Integer, default=-1) battery_as_of: Mapped[float] = mapped_column(Float, default=0.0) # Current discharge cycle only (reset on recharge detection); the # permanent record is the battery_log table. battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) last_seen: Mapped[float] = mapped_column(Float, default=0.0) device_firmware_version: Mapped[str] = mapped_column(String, default="") device_board_variant: Mapped[str] = mapped_column(String, default="") # Battery-low email alert (see routers/device.py's frame_battery). # -1 = disabled. Sent to the owner's email once per discharge cycle # (battery_alert_sent resets alongside battery_history whenever a # recharge is detected, same trigger as stats_recharge_cycles). battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1) battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False) # -- firmware / OTA (per frame; image lives at /data/firmware/.bin) -- firmware_available_version: Mapped[str] = mapped_column(String, default="") firmware_update_repo_url: Mapped[str] = mapped_column(String, default="") firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False) firmware_update_token: Mapped[str] = mapped_column(String, default="") firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0) firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="") # -- stats (flattened from the old nested FrameStats) -- stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0) stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0) stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0) stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0) stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0) stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0) stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0) stats_config_saves: Mapped[int] = mapped_column(Integer, default=0) owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id]) controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id]) class UserFrame(Base): """A user linked to a frame: sees it in their sidebar, may view its pages, and may take control. Ownership (whose Immich creds the frame renders from) is frames.owner_user_id, separate from linking.""" __tablename__ = "user_frames" user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), primary_key=True ) frame_id: Mapped[int] = mapped_column( ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True ) created_at: Mapped[float] = mapped_column(Float, default=time.time) class PendingClaim(Base): """A claim submitted before the frame's first check-in (the user beat the device to the server after provisioning). Attached automatically when a device with this id self-registers; expired rows are pruned opportunistically.""" __tablename__ = "pending_claims" device_id: Mapped[str] = mapped_column(String, primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) created_at: Mapped[float] = mapped_column(Float, default=time.time) expires_at: Mapped[float] = mapped_column(Float) class ServerSettings(Base): """Singleton row (id always 1) holding operator-level SMTP config, set from /admin -- not env vars, since this is infrastructure a household admin configures once through the UI rather than at container deploy time. Used for password-reset emails and battery-threshold alerts (see app/mail.py). smtp_host empty = email sending disabled; every send site checks that and no-ops rather than erroring.""" __tablename__ = "server_settings" id: Mapped[int] = mapped_column(primary_key=True) smtp_host: Mapped[str] = mapped_column(String, default="") smtp_port: Mapped[int] = mapped_column(Integer, default=587) smtp_username: Mapped[str] = mapped_column(String, default="") smtp_password: Mapped[str] = mapped_column(String, default="") smtp_from_address: Mapped[str] = mapped_column(String, default="") # "none" (plaintext, port 25 typically), "starttls" (upgrades a # plaintext connection, port 587 typically), or "ssl" (TLS from the # first byte -- a different handshake entirely, not just starttls() # skipped; port 465 typically). See app/mail.py. smtp_encryption: Mapped[str] = mapped_column(String, default="starttls") class PasswordResetToken(Base): """A single-use, time-limited "forgot password" link. token is the URL-safe secret itself (not hashed, like PendingClaim/manage_token -- it's a short-lived bearer credential emailed once, not a long-lived session secret).""" __tablename__ = "password_reset_tokens" token: Mapped[str] = mapped_column(String, primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) created_at: Mapped[float] = mapped_column(Float, default=time.time) expires_at: Mapped[float] = mapped_column(Float) class BatteryLog(Base): """Every battery report ever, per frame -- the permanent record behind the battery history chart (was a 20k-entry JSON array in config.json).""" __tablename__ = "battery_log" id: Mapped[int] = mapped_column(primary_key=True) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) ts: Mapped[float] = mapped_column(Float) percent: Mapped[int] = mapped_column(Integer) __table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)