Files
espresso_frame/server/app/models.py
T
tfaour 8e10ca540e
Build and push server image / build-and-push (push) Successful in 40s
Add SMTP email: password reset + per-frame battery-threshold alerts
Admin-configured SMTP (server/port/username/password/from address/
STARTTLS, a singleton server_settings row set from /admin -- not env
vars, since it's operator infrastructure a household admin sets up
once through the UI) powers two features, both requiring the relevant
user to have an email set in their own Settings:

- "Forgot password?" on /login emails a one-hour single-use reset link
  (password_reset_tokens table). The endpoint always returns the same
  generic "check your email" response regardless of whether the address
  matched an account, so it can't be used to enumerate registered users.
- A frame's Configuration tab can set a battery-alert threshold
  (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery
  emails the owner the first time a report drops to or below it, then
  stays quiet for the rest of that discharge cycle (battery_alert_sent,
  reset alongside battery_history whenever the existing recharge-jump
  detection fires) -- not once per wake.

New app/mail.py wraps stdlib smtplib (no new dependency); send_email()
never raises, so a broken mail server can't 500 a battery report or a
password-reset request. Schema migration v2 adds users.email and the
two frame columns via ALTER TABLE (safe against the live, already-
populated database) plus the two new tables via the existing
create_all-based migration runner.

Verified against a real (already-migrated, real user/frame data)
database: the v1->v2 migration, admin SMTP config + test-email button,
full forgot/reset-password roundtrip (including single-use token
invalidation and the no-enumeration response), and the battery alert
firing exactly once per crossing against a hand-rolled fake SMTP
server -- all via curl end-to-end, plus the standing legacy-device
curl suite to confirm the device protocol is untouched.
2026-07-22 00:51:54 -04:00

250 lines
12 KiB
Python

"""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/<id>.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="")
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
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"),)