From 8bc0749b423bf490408f08d7ddc052b4eb827cd7 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Fri, 24 Jul 2026 08:27:47 -0400 Subject: [PATCH] Widget system Phase 0: data model + migration First step of replacing Frame.mode (one renderer owns the whole panel) with an Android-home-screen-style widget system -- a frame will hold N independently placed/sized widgets (photos/calendar/whiteboard), each with its own config/state, plus fully user-assignable NEXT/BACK button actions. Full plan at .claude/plans/prancy-snacking-iverson.md. This phase is additive only and changes no existing behavior -- nothing reads these new tables yet: - models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/ WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this codebase's existing convention of dedicated tables for naturally-scoped state rather than one wide table) + FrameButtonAction (ordered (widget, action) bindings per physical button). - grid.py: pure snap-to-grid placement math, defined relative to the panel's long/short axis so it stays valid across logical_render_size(orientation)'s genuine width/height swap for portrait, not just a rotation applied at the end. - db.py: widget_locked(), the widget-scoped equivalent of frame_locked() -- deliberately still locks at frame granularity (not a new per-widget lock) to avoid a new class of multi-lock deadlock bugs. - migration.py: _migration_16 creates the new tables; a separate _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less error-prone for this much per-mode branching) gives every existing frame a widget reproducing its exact current mode/settings, so upgrading changes nothing about what a frame displays or what its buttons do. calendar_photo_inlay frames specifically get two widgets (calendar + photo, split like the old inlay did) rather than silently losing the photo half. 10 new tests covering fresh-install backfill, re-run idempotency, the photo-inlay two-widget case, whiteboard's check_now button mapping, and migration_16's actual CREATE TABLE path against a simulated pre-existing database (not just the fresh-install create_all() shortcut). Full suite (69 tests) passes. --- server/app/db.py | 31 ++++- server/app/grid.py | 82 +++++++++++ server/app/migration.py | 236 +++++++++++++++++++++++++++++++- server/app/models.py | 145 ++++++++++++++++++++ server/tests/test_migrations.py | 145 +++++++++++++++++++- 5 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 server/app/grid.py diff --git a/server/app/db.py b/server/app/db.py index d57c1a4..ed8a695 100644 --- a/server/app/db.py +++ b/server/app/db.py @@ -16,7 +16,7 @@ from typing import Iterator from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker -from .models import Frame +from .models import WIDGET_CONFIG_MODELS, Frame, Widget DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db") @@ -86,3 +86,32 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]: db.refresh(frame) yield frame db.commit() + + +@contextmanager +def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]: + """Same lock/refresh/commit dance as frame_locked, additionally + resolving and refreshing the widget's own per-type config row + (PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see + models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame* + granularity -- the exact same per-frame threading.Lock frame_locked + uses, not a separate per-widget lock -- simplest, avoids a new class + of multi-lock deadlock bugs, and this project's actual concurrency + needs are tiny (a handful of users per household frame). + + threading.Lock is not reentrant: a caller executing several widget + actions in one pass (e.g. a button press assigned multiple + (widget, action) pairs, see routers/device.py) MUST call this once + per action, sequentially, never nested inside an outer + frame_locked/widget_locked span for the same frame -- nesting would + deadlock instantly, not just misbehave.""" + with frame_locked(db, frame_id) as frame: + widget = db.get(Widget, widget_id) + if widget is None or widget.frame_id != frame_id: + raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}") + config_model = WIDGET_CONFIG_MODELS[widget.widget_type] + config = db.get(config_model, widget_id) + if config is None: + raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row") + db.refresh(config) + yield frame, widget, config diff --git a/server/app/grid.py b/server/app/grid.py new file mode 100644 index 0000000..60dca69 --- /dev/null +++ b/server/app/grid.py @@ -0,0 +1,82 @@ +"""Snap-to-grid placement math for widgets (see models.Widget) -- pure, +no I/O, no ORM. + +The grid is defined relative to the panel's long/short axis, not +landscape/portrait specifically, so it stays valid across +image_pipeline.logical_render_size(orientation)'s genuine width/height +swap for portrait (not just a rotation applied at the very end) -- +landscape orientations are GRID_LONG columns x GRID_SHORT rows, portrait +orientations are GRID_SHORT columns x GRID_LONG rows, same cell size +either way. Changing a frame's orientation therefore invalidates any +existing widget layout (an 8x5 arrangement isn't valid on a 5x8 grid) -- +callers are expected to reset to one full-panel widget on an orientation +change, not try to remap coordinates. +""" + +from __future__ import annotations + +GRID_LONG = 8 +GRID_SHORT = 5 + +# Per-widget-type minimum grid footprint (cols, rows) -- enforced both in +# the placement UI and server-side (routers/api_widgets.py). A calendar +# widget crammed into 1x1 would be illegible regardless of size-tier +# scaling (see calendar_render.py); whiteboard needs enough room to be +# worth looking at; photos can go as small as a single cell. +MIN_FOOTPRINT: dict[str, tuple[int, int]] = { + "photos": (1, 1), + "calendar": (3, 2), + "whiteboard": (2, 2), +} + +Rect = tuple[int, int, int, int] # (x, y, w, h) + + +def grid_dims(orientation: str) -> tuple[int, int]: + """(cols, rows) for this orientation.""" + if orientation in ("portrait", "portrait_flipped"): + return GRID_SHORT, GRID_LONG + return GRID_LONG, GRID_SHORT + + +def full_panel_rect(orientation: str) -> Rect: + """The single full-panel widget rect for this orientation -- what a + frame gets reset to whenever its layout can't carry over (initial + migration backfill, an orientation change).""" + cols, rows = grid_dims(orientation) + return (0, 0, cols, rows) + + +def in_bounds(orientation: str, rect: Rect) -> bool: + cols, rows = grid_dims(orientation) + x, y, w, h = rect + return x >= 0 and y >= 0 and w > 0 and h > 0 and x + w <= cols and y + h <= rows + + +def meets_minimum(widget_type: str, rect: Rect) -> bool: + min_w, min_h = MIN_FOOTPRINT.get(widget_type, (1, 1)) + _, _, w, h = rect + return w >= min_w and h >= min_h + + +def overlaps(a: Rect, b: Rect) -> bool: + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]: + """Grid rect -> pixel rect in logical (pre-rotation) canvas space -- + against image_pipeline.logical_render_size(orientation)'s own + (panel_w, panel_h), the same space every renderer already composes + in before the final orientation transpose.""" + cols, rows = grid_dims(orientation) + cell_w = panel_w / cols + cell_h = panel_h / rows + x, y, w, h = rect + px, py = round(x * cell_w), round(y * cell_h) + # Snap the far edge to the next cell boundary rather than compounding + # per-cell rounding error across w/h -- keeps adjacent widgets' + # shared edge pixel-exact instead of leaving a stray gap/overlap. + px2, py2 = round((x + w) * cell_w), round((y + h) * cell_h) + return (px, py, px2 - px, py2 - py) diff --git a/server/app/migration.py b/server/app/migration.py index 8583b4a..f331611 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -17,9 +17,19 @@ import time from sqlalchemy import select, text -from . import config +from . import config, grid from .db import SessionLocal, engine -from .models import Base, BatteryLog, Frame, ServerSettings +from .models import ( + Base, + BatteryLog, + CalendarWidgetConfig, + Frame, + FrameButtonAction, + PhotoWidgetConfig, + ServerSettings, + WhiteboardWidgetConfig, + Widget, +) logger = logging.getLogger(__name__) @@ -225,6 +235,104 @@ def _migration_15(conn) -> None: conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''")) +def _migration_16(conn) -> None: + """Widget system: a frame can now hold N independently placed/sized + widgets (photos/calendar/whiteboard) instead of exactly one mode-wide + renderer -- see models.py's Widget/PhotoWidgetConfig/ + CalendarWidgetConfig/WhiteboardWidgetConfig/FrameButtonAction, + app/grid.py, app/widgets/. + + This migration only creates the new (empty) tables -- it does NOT + backfill a widget per existing frame here. That backfill (reading + each frame's current mode/settings to build a widget that reproduces + its exact current display, including the calendar_photo_inlay -> + two-widgets special case) is real per-mode branching logic that's + much less error-prone written as typed ORM object construction than + as hand-written column-by-column SQL -- see _ensure_widgets_backfilled, + called unconditionally at the end of run_migrations() for both this + upgrade path AND the from-scratch _ensure_frame_one() path, so both + produce the same default-widget invariant from one place rather than + two separately-maintained ones. Every existing frame is briefly + widget-less between this migration and that call within the same + startup, not across restarts -- nothing reads these tables yet at + that point regardless.""" + conn.execute(text( + "CREATE TABLE widgets (" + "id INTEGER PRIMARY KEY, " + "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, " + "widget_type TEXT NOT NULL, " + "x INTEGER NOT NULL, " + "y INTEGER NOT NULL, " + "w INTEGER NOT NULL, " + "h INTEGER NOT NULL, " + "sort_order INTEGER NOT NULL DEFAULT 0, " + "created_at REAL NOT NULL DEFAULT 0.0)" + )) + conn.execute(text("CREATE INDEX ix_widgets_frame ON widgets (frame_id)")) + + conn.execute(text( + "CREATE TABLE photo_widget_configs (" + "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " + "album_id TEXT NOT NULL DEFAULT '', " + "photo_order TEXT NOT NULL DEFAULT 'sequential', " + "display_mode TEXT NOT NULL DEFAULT 'crop_faces', " + "queue_target_len INTEGER NOT NULL DEFAULT 20, " + "current_asset_id TEXT NOT NULL DEFAULT '', " + "current_asset_set_at REAL NOT NULL DEFAULT 0.0, " + "queue TEXT NOT NULL DEFAULT '[]', " + "queue_cursor INTEGER NOT NULL DEFAULT 0, " + "history TEXT NOT NULL DEFAULT '[]', " + "excluded_asset_ids TEXT NOT NULL DEFAULT '[]')" + )) + + conn.execute(text( + "CREATE TABLE calendar_widget_configs (" + "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " + "view TEXT NOT NULL DEFAULT 'agenda', " + "week_start INTEGER NOT NULL DEFAULT 0, " + "browse_offset INTEGER NOT NULL DEFAULT 0, " + "checked_at REAL NOT NULL DEFAULT 0.0, " + "cached_events TEXT, " + "fetch_summary TEXT NOT NULL DEFAULT '', " + "weather_enabled INTEGER NOT NULL DEFAULT 0, " + "weather_units TEXT NOT NULL DEFAULT 'fahrenheit', " + "weather_cities TEXT, " + "weather_checked_at REAL NOT NULL DEFAULT 0.0, " + "weather_cached TEXT, " + "week_days INTEGER NOT NULL DEFAULT 7, " + "week_layout TEXT NOT NULL DEFAULT 'horizontal', " + "week_start_offset INTEGER NOT NULL DEFAULT 0, " + "tasks_enabled INTEGER NOT NULL DEFAULT 0, " + "tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, " + "tasks_calendar_key TEXT, " + "tasks_checked_at REAL NOT NULL DEFAULT 0.0, " + "tasks_cached TEXT)" + )) + + conn.execute(text( + "CREATE TABLE whiteboard_widget_configs (" + "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " + "user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, " + "url TEXT NOT NULL DEFAULT '', " + "checked_at REAL NOT NULL DEFAULT 0.0, " + "cached_image BLOB)" + )) + + conn.execute(text( + "CREATE TABLE frame_button_actions (" + "id INTEGER PRIMARY KEY, " + "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, " + "button TEXT NOT NULL, " + "widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, " + "action TEXT NOT NULL, " + "sort_order INTEGER NOT NULL DEFAULT 0, " + "created_at REAL NOT NULL DEFAULT 0.0)" + )) + conn.execute(text( + "CREATE INDEX ix_frame_button_actions_frame_button ON frame_button_actions (frame_id, button, sort_order)" + )) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -241,6 +349,7 @@ MIGRATIONS = [ (13, _migration_13), (14, _migration_14), (15, _migration_15), + (16, _migration_16), ] @@ -268,6 +377,7 @@ def run_migrations() -> None: conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) _ensure_frame_one() _ensure_server_settings() + _ensure_widgets_backfilled() def new_device_token() -> str: @@ -372,3 +482,125 @@ def _ensure_server_settings() -> None: if db.get(ServerSettings, 1) is None: db.add(ServerSettings(id=1)) db.commit() + + +def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig: + return PhotoWidgetConfig( + widget_id=widget_id, + album_id=frame.album_id, + order=frame.order, + display_mode=frame.display_mode, + queue_target_len=frame.queue_target_len, + current_asset_id=frame.current_asset_id, + current_asset_set_at=frame.current_asset_set_at, + queue=list(frame.queue), + queue_cursor=frame.queue_cursor, + history=list(frame.history), + excluded_asset_ids=list(frame.excluded_asset_ids), + ) + + +def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig: + return CalendarWidgetConfig( + widget_id=widget_id, + view=frame.calendar_view, + week_start=frame.calendar_week_start, + browse_offset=frame.calendar_browse_offset, + checked_at=frame.calendar_checked_at, + cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None, + fetch_summary=frame.calendar_fetch_summary, + weather_enabled=frame.calendar_weather_enabled, + weather_units=frame.calendar_weather_units, + weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None, + weather_checked_at=frame.calendar_weather_checked_at, + weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None, + week_days=frame.calendar_week_days, + week_layout=frame.calendar_week_layout, + week_start_offset=frame.calendar_week_start_offset, + tasks_enabled=frame.calendar_tasks_enabled, + tasks_user_id=frame.calendar_tasks_user_id, + tasks_calendar_key=frame.calendar_tasks_calendar_key, + tasks_checked_at=frame.calendar_tasks_checked_at, + tasks_cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None, + ) + + +def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig: + return WhiteboardWidgetConfig( + widget_id=widget_id, + user_id=frame.whiteboard_user_id, + url=frame.whiteboard_url, + checked_at=frame.whiteboard_checked_at, + cached_image=frame.whiteboard_cached_image, + ) + + +def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]: + """NEXT/BACK -> whatever this widget's own advance/back concept is + (see app/widgets/ for the actual action registry, built in a later + phase) -- reproduces each mode's exact old button behavior for the + one auto-migrated widget, so upgrading changes nothing about what the + physical buttons do until someone deliberately reassigns them.""" + if widget_type == "whiteboard": + # No real "next"/"back" concept for a static board -- both + # buttons already meant "check now" before this migration (see + # the old _advance_whiteboard_mode/_back_whiteboard_mode). + return [ + FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"), + FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"), + ] + return [ + FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"), + FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"), + ] + + +def _backfill_frame_widgets(db, frame: Frame) -> None: + cols, rows = grid.grid_dims(frame.orientation) + mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos" + + if mode == "calendar" and frame.calendar_photo_inlay: + # Reproduces the old fixed 50/50 inlay split as two independent + # widgets instead of silently dropping half of what the frame was + # showing -- see models.py's CalendarWidgetConfig docstring on why + # "photo inlay" isn't a widget-system concept anymore otherwise. + half = cols // 2 + cal_widget = Widget(frame_id=frame.id, widget_type="calendar", + x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time()) + photo_widget = Widget(frame_id=frame.id, widget_type="photos", + x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time()) + db.add_all([cal_widget, photo_widget]) + db.flush() # assign ids before the FK'd config rows reference them + db.add(_calendar_config_from_frame(frame, cal_widget.id)) + db.add(_photo_config_from_frame(frame, photo_widget.id)) + db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar")) + return + + widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows, + sort_order=0, created_at=time.time()) + db.add(widget) + db.flush() + if mode == "photos": + db.add(_photo_config_from_frame(frame, widget.id)) + elif mode == "calendar": + db.add(_calendar_config_from_frame(frame, widget.id)) + elif mode == "whiteboard": + db.add(_whiteboard_config_from_frame(frame, widget.id)) + db.add_all(_default_button_actions(frame.id, widget.id, mode)) + + +def _ensure_widgets_backfilled() -> None: + """Every frame needs at least one Widget once the widget system is + live -- runs unconditionally after every startup (both a from-scratch + _ensure_frame_one() install and an existing-install upgrade past + _migration_16 land here) and is a no-op for any frame that already + has one. Builds a widget that reproduces the frame's current mode/ + settings/state exactly, so upgrading never changes what a frame + displays or what its physical buttons do on its own.""" + with SessionLocal() as db: + for frame in db.scalars(select(Frame)).all(): + has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first() + if has_widget is not None: + continue + _backfill_frame_widgets(db, frame) + db.commit() diff --git a/server/app/models.py b/server/app/models.py index fb0ebf2..4c22a5c 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -380,6 +380,151 @@ class FrameCalendar(Base): ) +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" + 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). "Included + calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed + to widget_id in a later phase once more than one calendar widget per + frame is actually supported end to end).""" + + __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) + tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + tasks_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True) + tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0) + 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, +} + + +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 diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index d2490cd..a2b4f20 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -6,11 +6,22 @@ has to tolerate being invoked against an already-current database).""" from __future__ import annotations -from sqlalchemy import inspect, text +import time + +from sqlalchemy import inspect, select, text from app import db as db_module +from app import grid from app.migration import MIGRATIONS, run_migrations -from app.models import Frame, ServerSettings +from app.models import ( + CalendarWidgetConfig, + Frame, + FrameButtonAction, + PhotoWidgetConfig, + ServerSettings, + Widget, + WhiteboardWidgetConfig, +) def test_migrations_list_is_sequential_and_unique(): @@ -57,3 +68,133 @@ def test_expected_columns_exist_on_current_schema(): assert "calendar_caldav_url" in user_columns assert "whiteboard_cached_image" in frame_columns # migration 14 assert "calendar_week_start_offset" in frame_columns + + +# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- + + +def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_session): + frame = db_session.get(Frame, 1) + assert frame.mode == "photos" + + widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() + assert len(widgets) == 1 + widget = widgets[0] + assert widget.widget_type == "photos" + assert (widget.x, widget.y, widget.w, widget.h) == grid.full_panel_rect(frame.orientation) + + config = db_session.get(PhotoWidgetConfig, widget.id) + assert config is not None + assert config.album_id == frame.album_id + + actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() + assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"} + assert all(a.widget_id == widget.id for a in actions) + + +def test_rerunning_migrations_does_not_duplicate_widgets(db_session): + run_migrations() + run_migrations() + widgets = db_session.scalars(select(Widget).where(Widget.frame_id == 1)).all() + assert len(widgets) == 1 + + +def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session): + """Reproduces the old fixed 50/50 inlay split as two independent, + non-overlapping widgets instead of silently dropping the photo half + on upgrade -- see models.py's CalendarWidgetConfig docstring.""" + frame = Frame( + name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay", + mode="calendar", orientation="landscape", calendar_view="week", + calendar_photo_inlay=True, album_id="album-123", + current_asset_id="asset-1", queue=["asset-1", "asset-2"], + created_at=time.time(), + ) + db_session.add(frame) + db_session.commit() + + run_migrations() + + widgets = db_session.scalars( + select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) + ).all() + assert len(widgets) == 2 + cal_widget, photo_widget = widgets + assert cal_widget.widget_type == "calendar" + assert photo_widget.widget_type == "photos" + + cal_rect = (cal_widget.x, cal_widget.y, cal_widget.w, cal_widget.h) + photo_rect = (photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h) + assert not grid.overlaps(cal_rect, photo_rect) + assert cal_widget.w + photo_widget.w == grid.grid_dims("landscape")[0] + assert cal_widget.h == photo_widget.h == grid.grid_dims("landscape")[1] + + cal_config = db_session.get(CalendarWidgetConfig, cal_widget.id) + assert cal_config.view == "week" + photo_config = db_session.get(PhotoWidgetConfig, photo_widget.id) + assert photo_config.album_id == "album-123" + assert photo_config.current_asset_id == "asset-1" + assert photo_config.queue == ["asset-1", "asset-2"] + + actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() + assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"} + assert all(a.widget_id == cal_widget.id for a in actions) + + +def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session): + frame = Frame( + name="WB Frame", device_token="tok-wb", manage_token="mtok-wb", + mode="whiteboard", orientation="portrait", + whiteboard_url="https://example.com/board.whiteboard", + created_at=time.time(), + ) + db_session.add(frame) + db_session.commit() + + run_migrations() + + widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() + assert len(widgets) == 1 + widget = widgets[0] + assert widget.widget_type == "whiteboard" + assert (widget.x, widget.y, widget.w, widget.h) == grid.full_panel_rect("portrait") + + config = db_session.get(WhiteboardWidgetConfig, widget.id) + assert config.url == "https://example.com/board.whiteboard" + + actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() + assert {a.action for a in actions} == {"check_now"} + + +def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db_session): + """Exercises _migration_16's actual CREATE TABLE statements (the real + "existing production database upgrading past this migration" + scenario) rather than the fresh-install create_all() shortcut, which + every other test in this file goes through instead.""" + with db_module.engine.begin() as conn: + for table in ("frame_button_actions", "whiteboard_widget_configs", + "calendar_widget_configs", "photo_widget_configs", "widgets"): + conn.execute(text(f"DROP TABLE {table}")) + conn.execute(text("UPDATE schema_version SET version = 15")) + + frame = Frame( + name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up", + mode="photos", album_id="legacy-album", current_asset_id="legacy-asset", + queue=["legacy-asset", "next-asset"], created_at=time.time(), + ) + db_session.add(frame) + db_session.commit() + frame_id = frame.id + + run_migrations() + + with db_module.engine.begin() as conn: + version = conn.execute(text("SELECT version FROM schema_version")).scalar() + assert version == MIGRATIONS[-1][0] + + widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame_id)).all() + assert len(widgets) == 1 + config = db_session.get(PhotoWidgetConfig, widgets[0].id) + assert config.album_id == "legacy-album" + assert config.current_asset_id == "legacy-asset" + assert config.queue == ["legacy-asset", "next-asset"]