"""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 _migration_17, 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 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. 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. 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 []).""" __tablename__ = "task_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) calendar_key: Mapped[str | None] = mapped_column(String, nullable=True) 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. cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) 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) # 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, } 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"),)