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.
This commit is contained in:
+30
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+234
-2
@@ -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()
|
||||
|
||||
@@ -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: <photo widget>, 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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user