"""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, LargeBinary, 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="") # Personal ICS subscription URL (no OAuth) for calendar frame mode -- # see calendar_feed.py. Setting this alone shows up nowhere: a linked # frame only pulls this user's events in once they've also added it # on that frame's own Calendar tab (FrameCalendar below). calendar_ics_url: Mapped[str] = mapped_column(String, default="") # A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the # plain ICS subscription above -- see caldav_client.py. calendar_url # is the server's CalDAV entry point the user pasted in, not any one # calendar's own URL; the individual calendars it exposes are # discovered and cached below. calendar_caldav_url: Mapped[str] = mapped_column(String, default="") calendar_caldav_username: Mapped[str] = mapped_column(String, default="") calendar_caldav_password: Mapped[str] = mapped_column(String, default="") # [{"href", "display_name"}, ...] from the last successful # caldav_client.discover_calendars() call, refreshed by Settings' # "Discover calendars" button -- NULL until discovery has ever # succeeded. This is what a frame's Calendar tab offers the user to # add, without hitting the CalDAV server on every page load. calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0) # WebDAV credentials for whiteboard frame mode (see webdav_client.py, # whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but # webdav_reuse_caldav_creds is a convenience for the common case # where it IS the same Nextcloud account as calendar_caldav_*: skip # re-entering the same username/password, since Nextcloud's CalDAV # and general-file-WebDAV both sit under the one account. Doesn't # try to be clever and derive the reuse automatically -- an explicit # opt-in, same as everywhere else in this project defaults features # off rather than silently inferring them. webdav_username: Mapped[str] = mapped_column(String, default="") webdav_password: Mapped[str] = mapped_column(String, default="") webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False) # Optional starting folder for the whiteboard dialog's file-picker # (see routers/api_widgets.py's api_widget_whiteboard_browse) -- # purely a convenience for browsing to a file rather than typing its # full URL. # Never used for fetching/rendering itself, which always uses the # frame's own saved whiteboard_url regardless of whether this is set. webdav_base_url: 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 -- "photos", "calendar", or "whiteboard" # (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS # and routers/common.py FRAME_MODES). 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") # How a photo's aspect ratio is reconciled with the panel's -- see # image_pipeline.DISPLAY_MODES. display_mode: Mapped[str] = mapped_column(String, default="crop_faces") orientation: Mapped[str] = mapped_column(String, default="landscape") queue_target_len: Mapped[int] = mapped_column(Integer, default=20) # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/ # blue/green, matching image_pipeline.PANEL_CODES order) overriding # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the # default -- most frames never touch this. palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged # (see image_pipeline.render_frame). color_boost: Mapped[float] = mapped_column(Float, default=1.0) contrast_boost: Mapped[float] = mapped_column(Float, default=1.0) # 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's # original always-on full-strength Floyd-Steinberg dithering. dither_strength: Mapped[float] = mapped_column(Float, default=1.0) # -- calendar mode (see calendar_feed.py, calendar_render.py, # routers/device.py's RENDERERS["calendar"]) -- calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month" # 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) -- # which day week/month views start their grid on. calendar_week_start: Mapped[int] = mapped_column(Integer, default=0) # Agenda view only; reuses this frame's existing photos-mode album/ # queue, not a separate photo setup. calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False) # How many periods (unit depends on calendar_view: days/weeks/months) # NEXT/BACK have browsed from "today". Reset to 0 by the next normal # (non-button) /frame/image request, and whenever calendar_view # itself changes -- a stale offset means something different in a # different view's units. calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0) # Throttled merge-fetch cache (see routers/common.py's # get_or_refresh_calendar_events) -- same shape as the # firmware_update_checked_at/firmware_gitea_latest_version pattern # below. One shared cache for every included user's merged events, # not per-user. calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0) calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # "" when the last merge-fetch fully succeeded, else e.g. "1 of 2 # calendars unavailable" -- never names which user's feed failed, a # shared household display shouldn't call out a specific person's # outage to everyone who looks at it. calendar_fetch_summary: Mapped[str] = mapped_column(String, default="") # Optional weather strip, agenda/today & tomorrow/week views only -- # never month, there's no room (see calendar_render.py's _BUILDERS). # Off by default. calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False) calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius" # [{"label", "latitude", "longitude"}, ...] -- each geocoded once via # weather.geocode_city() when added from the Calendar tab. calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # Throttled per-city forecast cache (see routers/common.py's # get_or_refresh_weather) -- same shape idiom as # calendar_checked_at/calendar_cached_events above. # [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...] calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0) calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # Week view: how many days to show (2-10, default 7 -- the original # fixed behavior) and whether they're laid out as side-by-side # columns or stacked bands (see calendar_render.py's _build_week). calendar_week_days: Mapped[int] = mapped_column(Integer, default=7) calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical" # Only used when calendar_week_days != 7 -- 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 view instead starts this many days from today (0 = # starts today, negative = starts in the past, positive = starts in # the future). Ignored (calendar_week_start governs instead) at the # default 7 days, so this has no effect until someone actually # changes the day count. calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0) # Optional task list, week view only -- takes the space of one day # slot rather than adding an extra one (see calendar_render.py's # _draw_tasks). CalDAV only (a task list is a VTODO collection, not # something a plain ICS subscription meaningfully has); source is # one specific linked user's own CalDAV calendar, same # owner-controls-their-own-data permission split as FrameCalendar. # calendar_tasks_user_id # SET NULL on the user's deletion clears the source rather than # leaving a dangling reference (checked_at isn't reset by that, but # the next refresh attempt finds no source and just returns []). calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False) calendar_tasks_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True) calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0) # [{"summary", "due" (ISO date/datetime string or None)}, ...], # already filtered to outstanding (not-completed) tasks and sorted # by due date -- see caldav_client.fetch_tasks. calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # -- whiteboard mode (see webdav_client.py, whiteboard.py, # routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide # setting like calendar mode's own frame_calendars source, not # personal data, but still owner-gated the same way: only # whiteboard_user_id may point the frame at their own account, since # it's their credentials being used to fetch it. -- whiteboard_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) # The specific .whiteboard file's WebDAV URL -- pasted directly, same # idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's # account-has-several-calendars case, a WebDAV account doesn't need # a picker step here since the user already knows which one file). whiteboard_url: Mapped[str] = mapped_column(String, default="") whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0) # Cached rendered PNG bytes (see whiteboard.fetch_and_render) -- # BLOB rather than the JSON columns the rest of this cache-pattern # family uses, since this is binary image data, not JSON-shaped. whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) # -- 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 FrameCalendar(Base): """One calendar included on one frame -- calendar_key is "ics" (the owner's single calendar_ics_url) or "caldav:" (one of the owner's CalDAV collections; href matches an entry in User.calendar_caldav_calendars). Replaces the old single UserFrame.calendar_included boolean now that a CalDAV account can expose more than one calendar. A row only ever gets created by its own owner (adding a calendar to a frame is each person's own data-sharing choice, not something a frame's controller decides on their behalf) -- but once it exists, ANY user linked to the frame may flip included back to False, muting a calendar they'd rather not see on a shared display even though they don't own it. Only the owner may flip it back to True. See routers/api_widgets.py's api_widget_calendar_select. Keyed by widget_id, not frame_id -- a frame can hold more than one independent calendar widget (see Widget), each with its own included- calendars set; "included on this frame" stopped being unambiguous the moment that became possible (see migration.py's _ensure_frame_calendars_rekeyed, which re-keyed this table).""" __tablename__ = "frame_calendars" id: Mapped[int] = mapped_column(primary_key=True) widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) calendar_key: Mapped[str] = mapped_column(String) # Snapshot label for display -- so the list still reads sensibly even # if the owner's CalDAV account later stops offering this calendar. calendar_label: Mapped[str] = mapped_column(String, default="") included: Mapped[bool] = mapped_column(Boolean, default=True) # Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5: # Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's # text/background) pinning this calendar's events to a specific # panel color rather than calendar_render.py's old owner-name # auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's # owner may set this -- see routers/api_widgets.py's api_widget_calendar_color. color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) __table_args__ = ( Index("ix_frame_calendars_unique", "widget_id", "user_id", "calendar_key", unique=True), ) class FrameTaskList(Base): """One CalDAV task list included on one tasks widget -- calendar_key is "caldav:" (an entry in User.calendar_caldav_calendars; no "ics" variant, unlike FrameCalendar -- a plain ICS subscription has no VTODO collection). Same owner-controls-their-own-data shape as FrameCalendar in every other respect: a row is only ever created by its own owner, but any user linked to the frame may flip included back to False, and only the owner may flip it back to True or set color_index. See routers/api_widgets.py's api_widget_task_list_select/ api_widget_task_list_color.""" __tablename__ = "frame_task_lists" id: Mapped[int] = mapped_column(primary_key=True) widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) calendar_key: Mapped[str] = mapped_column(String) calendar_label: Mapped[str] = mapped_column(String, default="") included: Mapped[bool] = mapped_column(Boolean, default=True) color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) __table_args__ = ( Index("ix_frame_task_lists_unique", "widget_id", "user_id", "calendar_key", unique=True), ) class Widget(Base): """One placed/sized content item on a frame's panel -- the unit the widget system replaces the old single Frame.mode with (see app/grid.py for the grid this x/y/w/h is measured in, and app/widgets/ for the widget_type -> render/action dispatch registry). Widgets never overlap (enforced server-side in routers/api_widgets.py), which is what keeps compositing simple: no z-order, no blending, just N independent regions pasted onto one shared canvas before a single shared dither/quantize pass (see image_pipeline.render_panel). widget_type selects which of the three per-type extension tables below (PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig) holds this widget's actual settings/state -- a 1:1 relational split rather than one wide table with every type's columns, matching how FrameCalendar/BatteryLog are already their own tables in this codebase rather than crammed onto Frame.""" __tablename__ = "widgets" id: Mapped[int] = mapped_column(primary_key=True) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" x: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer) w: Mapped[int] = mapped_column(Integer) h: Mapped[int] = mapped_column(Integer) # Display/tie-break ordering only (e.g. listing widgets in a UI) -- # NOT a z-order, since widgets never overlap. Named sort_order, not # order, to sidestep the SQL-keyword dance Frame.order needed # (mapped to a differently-named column) -- nothing outside this # table needs to match a specific attribute name here. sort_order: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[float] = mapped_column(Float, default=time.time) __table_args__ = (Index("ix_widgets_frame", "frame_id"),) class PhotoWidgetConfig(Base): """One photo widget's settings + queue state. Attribute names match Frame's old photo-queue columns exactly (down to `order`'s same photo_order column-name dodge) -- app/photo_queue.py's 5 functions are duck-typed against these exact names (never isinstance-checked against Frame), so they port unchanged onto this table.""" __tablename__ = "photo_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) album_id: Mapped[str] = mapped_column(String, default="") order: Mapped[str] = mapped_column("photo_order", String, default="sequential") display_mode: Mapped[str] = mapped_column(String, default="crop_faces") queue_target_len: Mapped[int] = mapped_column(Integer, default=20) 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) class CalendarWidgetConfig(Base): """One calendar widget's settings + cached-fetch state -- the same fields that used to live as calendar_* columns directly on Frame, minus calendar_photo_inlay (dropped: arbitrary widget placement subsumes what a fixed 50/50 inlay split did, so it's not a special case anymore, just place a photo widget alongside) and minus tasks_* (also dropped: split out into its own standalone widget type, see TaskWidgetConfig, so a task list isn't tied to a calendar's week view/footprint anymore). "Included calendars" is its own table (FrameCalendar), widget_id-keyed so each calendar widget on a frame has its own independent set.""" __tablename__ = "calendar_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) view: Mapped[str] = mapped_column(String, default="agenda") week_start: Mapped[int] = mapped_column(Integer, default=0) browse_offset: Mapped[int] = mapped_column(Integer, default=0) checked_at: Mapped[float] = mapped_column(Float, default=0.0) cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) fetch_summary: Mapped[str] = mapped_column(String, default="") weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False) weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0) weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) week_days: Mapped[int] = mapped_column(Integer, default=7) week_layout: Mapped[str] = mapped_column(String, default="horizontal") week_start_offset: Mapped[int] = mapped_column(Integer, default=0) class TaskWidgetConfig(Base): """One tasks widget's settings + cached-fetch state -- split out of CalendarWidgetConfig (which used to carry these as tasks_* columns, a week-view-only task list bolted onto a calendar widget) so a task list can be placed and sized on its own, independent of any calendar's view/footprint. No separate "enabled" flag -- unlike the old bolted-on version, the widget's mere presence on the grid is the on/off switch, same as every other widget type. Which task lists feed this widget lives in FrameTaskList, not here -- a widget can merge more than one person's list, mirroring CalendarWidgetConfig/FrameCalendar exactly (this used to be a single user_id/calendar_key pair here, one list only; migration 18 carried each widget's existing single source forward as its first FrameTaskList row when splitting this out).""" __tablename__ = "task_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) # Shown on-panel in place of the default "Tasks" header (see # calendar_render._draw_tasks) -- "" keeps the default. 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 way a calendar/photo/whiteboard's is. name: Mapped[str] = mapped_column(String, default="") checked_at: Mapped[float] = mapped_column(Float, default=0.0) # [{"summary", "due", "completed_at" (ISO date/datetime strings or # None), "owner_display_name", "color_index"}, ...] -- the merged # multi-list result, same general shape as CalendarWidgetConfig. # cached_events. See caldav_client.merge_tasks. cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # Also include tasks completed in the last 24h (drawn checked-box + # muted, see calendar_render._draw_tasks) rather than just # outstanding ones -- off by default, same "opt into more" posture # as calendar_weather_enabled. show_completed: Mapped[bool] = mapped_column(Boolean, default=False) class WhiteboardWidgetConfig(Base): """One whiteboard widget's source + rendered-PNG cache -- the same fields that used to live as whiteboard_* columns directly on Frame.""" __tablename__ = "whiteboard_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) url: Mapped[str] = mapped_column(String, default="") checked_at: Mapped[float] = mapped_column(Float, default=0.0) cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) class TextWidgetConfig(Base): """One text widget's authored content + display settings -- another no-live-upstream type like StaticWidgetConfig, just parsed rich text instead of an uploaded image. content is never raw HTML: the dialog's contenteditable innerHTML is parsed server-side (see app/text_content.py, the sanitization boundary) into this plain run structure at save time, so render() (app/widgets/text.py) never re-parses/sanitizes HTML on every panel refresh, and the dialog never re-injects stored HTML via innerHTML when reopened. [[{"text","bold","italic","underline","color","bg"}, ...], ...] -- outer list is paragraphs (line breaks), inner list is styled runs within that paragraph. color/bg are "#rrggbb" or null (falls back to black text / no highlight). NULL (not just []) means never configured, matching StaticWidgetConfig.image's None-vs-empty convention for "not configured yet".""" __tablename__ = "text_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) # Base point size for the whole block -- render() shrinks this down # (never up) to fit the widget's actual box; per-run font size isn't # supported, only the bold/italic/underline/color/bg style flags are # per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit # layout in app/widgets/text.py to one size per render pass. font_size: Mapped[int] = mapped_column(Integer, default=28) align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right" background_color: Mapped[str] = mapped_column(String, default="#ffffff") class StaticWidgetConfig(Base): """One static-image widget's uploaded content + display settings -- unlike every other widget type, this one has no live upstream to poll (Immich/CalDAV/WebDAV): the "source" is whatever the user last uploaded (see routers/api_widgets.py's api_widget_static_upload, app/image_upload.py), decoded once at upload time into plain RGB PNG bytes so app/widgets/static_image.py's render() never re-runs PDF/GIF decoding on every panel refresh.""" __tablename__ = "static_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) original_filename: Mapped[str] = mapped_column(String, default="") uploaded_at: Mapped[float] = mapped_column(Float, default=0.0) # Same DISPLAY_MODES vocabulary as PhotoWidgetConfig.display_mode, # minus crop_faces -- no face detection for an uploaded image (see # image_pipeline.STATIC_DISPLAY_MODES). display_mode: Mapped[str] = mapped_column(String, default="crop_fill") # widget_type -> its per-type extension table, keyed by widget_id. Used # by db.widget_locked() to resolve the right config row without importing # app/widgets/'s heavier render/action registry just for this lookup. WIDGET_CONFIG_MODELS: dict[str, type] = { "photos": PhotoWidgetConfig, "calendar": CalendarWidgetConfig, "whiteboard": WhiteboardWidgetConfig, "tasks": TaskWidgetConfig, "static": StaticWidgetConfig, "text": TextWidgetConfig, } class FrameButtonAction(Base): """One (widget, action) binding for one of a frame's two physical buttons -- e.g. {button: "next", widget_id: , action: "advance"}. A button can have several of these (sort_order gives execution order); on a press, every row for that (frame, button) runs -- see routers/device.py's frame_advance/frame_back. Deliberately unconstrained about which widget/action pairs with which button (the user's own idea for resolving "what does NEXT even mean with several widgets on screen": let them assign literally anything to either button, including mismatched combinations, rather than the server guessing a sensible default).""" __tablename__ = "frame_button_actions" id: Mapped[int] = mapped_column(primary_key=True) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) button: Mapped[str] = mapped_column(String) # "next" | "back" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE")) action: Mapped[str] = mapped_column(String) # e.g. "advance", "back", "check_now" -- see app/widgets/ sort_order: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[float] = mapped_column(Float, default=time.time) __table_args__ = ( Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"), ) 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"),)