Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns (mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc) docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup, with a raw-SQL backfill safety net for any frame that still somehow lacks a Widget. Also drops legacy_token_enabled and the shared MANAGEMENT_TOKEN fallback it gated in require_device/require_browser -- the per-frame manage_token/device_token flow (and the /m/ page) fully supersede it now; MANAGEMENT_TOKEN's only remaining role is the optional pre-setup claim gate. Confirmed with the maintainer that the deployed frame is already off the shared token before removing the server-side fallback. Firmware: the captive portal's "Access Token" field and its NVS/ build_url plumbing only ever mattered for pointing new firmware at an old pre-multi-frame server -- gone along with the server-side fallback it fed. Version bump to publish the change.
641 lines
31 KiB
Python
641 lines
31 KiB
Python
"""migration.py structural sanity: MIGRATIONS is well-formed, a fresh
|
|
install lands on the latest schema version with frame #1 + server
|
|
settings seeded, and re-running run_migrations() is a true no-op (it's
|
|
called unconditionally at every app.main import -- see main.py -- so it
|
|
has to tolerate being invoked against an already-current database)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 (
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
ServerSettings,
|
|
TaskWidgetConfig,
|
|
Widget,
|
|
WhiteboardWidgetConfig,
|
|
)
|
|
|
|
from .conftest import make_user
|
|
|
|
# Columns migration 41 drops from `frames` -- a fresh-install create_all()
|
|
# copy (what every db_session fixture starts from) already reflects
|
|
# today's models.py, i.e. the post-41 shape without these, so a test that
|
|
# wants to simulate a pre-41 database has to add them back itself before
|
|
# setting schema_version below 41 and calling run_migrations() -- same
|
|
# "frames isn't dropped/recreated by these replay tests" situation
|
|
# test_migration_29/30's own comments describe, just for columns being
|
|
# removed instead of added.
|
|
_LEGACY_FRAME_COLUMNS = [
|
|
"mode TEXT NOT NULL DEFAULT 'photos'",
|
|
"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 '[]'",
|
|
"calendar_view TEXT NOT NULL DEFAULT 'agenda'",
|
|
"calendar_week_start INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_photo_inlay INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_browse_offset INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"calendar_cached_events TEXT",
|
|
"calendar_fetch_summary TEXT NOT NULL DEFAULT ''",
|
|
"calendar_weather_enabled INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'",
|
|
"calendar_weather_cities TEXT",
|
|
"calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"calendar_weather_cached TEXT",
|
|
"calendar_week_days INTEGER NOT NULL DEFAULT 7",
|
|
"calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'",
|
|
"calendar_week_start_offset INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
|
|
"calendar_tasks_calendar_key TEXT",
|
|
"calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"calendar_tasks_cached TEXT",
|
|
"whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
|
|
"whiteboard_url TEXT NOT NULL DEFAULT ''",
|
|
"whiteboard_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"whiteboard_cached_image BLOB",
|
|
"legacy_token_enabled INTEGER NOT NULL DEFAULT 0",
|
|
]
|
|
|
|
|
|
def _add_legacy_frame_columns(conn) -> None:
|
|
existing = {c["name"] for c in inspect(db_module.engine).get_columns("frames")}
|
|
for col_def in _LEGACY_FRAME_COLUMNS:
|
|
if col_def.split()[0] not in existing:
|
|
conn.execute(text(f"ALTER TABLE frames ADD COLUMN {col_def}"))
|
|
|
|
|
|
def test_migrations_list_is_sequential_and_unique():
|
|
versions = [v for v, _ in MIGRATIONS]
|
|
assert versions == sorted(versions)
|
|
assert len(versions) == len(set(versions))
|
|
assert versions == list(range(1, len(versions) + 1))
|
|
|
|
|
|
def test_fresh_install_lands_on_latest_version(db_session):
|
|
with db_module.engine.connect() as conn:
|
|
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
|
assert row is not None
|
|
assert row[0] == MIGRATIONS[-1][0]
|
|
|
|
|
|
def test_fresh_install_seeds_frame_one_and_server_settings(db_session):
|
|
frame = db_session.get(Frame, 1)
|
|
assert frame is not None
|
|
assert frame.name
|
|
|
|
settings = db_session.get(ServerSettings, 1)
|
|
assert settings is not None
|
|
|
|
|
|
def test_rerunning_migrations_is_a_no_op(db_session):
|
|
frame_count_before = len(db_session.query(Frame).all())
|
|
run_migrations()
|
|
run_migrations()
|
|
frame_count_after = len(db_session.query(Frame).all())
|
|
assert frame_count_before == frame_count_after == 1
|
|
|
|
|
|
def test_expected_columns_exist_on_current_schema():
|
|
"""A light spot-check, not exhaustive -- one column from a handful of
|
|
the more recent migrations, to catch an ALTER that silently didn't
|
|
apply (e.g. a typo'd table/column name in a migration function)."""
|
|
inspector = inspect(db_module.engine)
|
|
user_columns = {c["name"] for c in inspector.get_columns("users")}
|
|
frame_columns = {c["name"] for c in inspector.get_columns("frames")}
|
|
task_widget_columns = {c["name"] for c in inspector.get_columns("task_widget_configs")}
|
|
|
|
assert "webdav_base_url" in user_columns # migration 15
|
|
assert "webdav_username" in user_columns # migration 14
|
|
assert "calendar_caldav_url" in user_columns
|
|
assert "name" in task_widget_columns # migration 19
|
|
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
|
assert "text_widget_configs" in inspector.get_table_names() # migration 21
|
|
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
|
|
assert "font_family" in text_widget_columns # migration 22
|
|
assert "weather_widget_configs" in inspector.get_table_names() # migration 24
|
|
weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
|
|
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns
|
|
assert "battery_widget_configs" in inspector.get_table_names() # migration 25
|
|
battery_widget_columns = {c["name"] for c in inspector.get_columns("battery_widget_configs")}
|
|
assert "mode" in battery_widget_columns
|
|
widget_columns = {c["name"] for c in inspector.get_columns("widgets")}
|
|
assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26
|
|
photo_widget_columns = {c["name"] for c in inspector.get_columns("photo_widget_configs")}
|
|
assert "locked" in photo_widget_columns # migration 27
|
|
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
|
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
|
|
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
|
|
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
|
|
assert "render_style" in weather_widget_columns # migration 31
|
|
assert {"photo_palette_rgb", "photo_dither_strength"} <= frame_columns # migration 32
|
|
assert "render_style" in battery_widget_columns # migration 33
|
|
assert "render_style" in text_widget_columns # migration 34
|
|
assert "render_style" in task_widget_columns # migration 35
|
|
static_widget_columns = {c["name"] for c in inspector.get_columns("static_widget_configs")}
|
|
assert "render_style" in static_widget_columns # migration 36
|
|
whiteboard_widget_columns = {c["name"] for c in inspector.get_columns("whiteboard_widget_configs")}
|
|
assert "render_style" in whiteboard_widget_columns # migration 37
|
|
calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")}
|
|
assert "render_style" in calendar_widget_columns # migration 38
|
|
assert "theme" in frame_columns # migration 39
|
|
assert "font_scale" in widget_columns # migration 40
|
|
assert not {"mode", "album_id", "current_asset_id", "calendar_view", "whiteboard_url",
|
|
"legacy_token_enabled"} & frame_columns # migration 41
|
|
|
|
|
|
# --- widget system: fresh-install default widget, and migration 41's
|
|
# raw-SQL backfill safety net for a pre-widget-system database ---
|
|
|
|
|
|
def test_fresh_install_creates_a_default_photos_widget_with_default_buttons(db_session):
|
|
frame = db_session.get(Frame, 1)
|
|
|
|
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 == ""
|
|
|
|
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. Exercises
|
|
_migration_41's raw-SQL backfill safety net: a frame whose legacy
|
|
Frame columns (pre-widget-system) still carry real data but which
|
|
somehow has no Widget yet."""
|
|
frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
|
|
orientation="landscape", created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
frame_id = frame.id
|
|
db_session.commit()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text(
|
|
"UPDATE frames SET mode='calendar', calendar_view='week', calendar_photo_inlay=1, "
|
|
"album_id='album-123', current_asset_id='asset-1', queue='[\"asset-1\", \"asset-2\"]' "
|
|
"WHERE id = :id"
|
|
), {"id": frame_id})
|
|
conn.execute(text("UPDATE schema_version SET version = 40"))
|
|
|
|
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):
|
|
"""Exercises _migration_41's raw-SQL backfill safety net for a
|
|
whiteboard-mode legacy frame -- same shape as the calendar-inlay case
|
|
above, just the simpler single-widget mode dispatch branch."""
|
|
frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
|
|
orientation="portrait", created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
frame_id = frame.id
|
|
db_session.commit()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text(
|
|
"UPDATE frames SET mode='whiteboard', "
|
|
"whiteboard_url='https://example.com/board.whiteboard' WHERE id = :id"
|
|
), {"id": frame_id})
|
|
conn.execute(text("UPDATE schema_version SET version = 40"))
|
|
|
|
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. frame_calendars
|
|
also gets rebuilt back to its pre-rekey (frame_id-keyed) shape,
|
|
matching what _migration_9/_migration_11 originally produced, so
|
|
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
|
|
migrate."""
|
|
with db_module.engine.begin() as conn:
|
|
# static_widget_configs/text_widget_configs are migration 20/21
|
|
# tables, saved_layouts/saved_layout_widgets/saved_layout_
|
|
# sources/saved_layout_button_actions are migration 23's,
|
|
# weather_widget_configs is migration 24's, and
|
|
# battery_widget_configs is migration 25's (all post-16, like the
|
|
# rest of this list) -- dropped here too so a real version-15
|
|
# database is what's actually being simulated, not "version 15
|
|
# plus tables that wouldn't exist yet". Harmless to omit as long
|
|
# as no migration after the one that creates a table also ALTERs
|
|
# or re-CREATEs it (that's what let a create_all-based migration
|
|
# go unlisted safely so far), but static_widget_configs/
|
|
# text_widget_configs/weather_widget_configs/battery_widget_
|
|
# configs all use a raw CREATE TABLE (not create_all -- see
|
|
# migration 20's own docstring on why), so an already-present one
|
|
# is a real "table already exists" collision, not a silent no-op.
|
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
|
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
|
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
|
"saved_layout_widgets", "saved_layouts", "weather_widget_configs",
|
|
"battery_widget_configs", "widgets"):
|
|
conn.execute(text(f"DROP TABLE {table}"))
|
|
conn.execute(text("DROP TABLE frame_calendars"))
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
|
))
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text("UPDATE schema_version SET version = 15"))
|
|
|
|
frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
|
|
created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
user = make_user(db_session, "legacy-owner")
|
|
db_session.commit()
|
|
frame_id, user_id = frame.id, user.id
|
|
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text(
|
|
"UPDATE frames SET mode='photos', album_id='legacy-album', current_asset_id='legacy-asset', "
|
|
"queue='[\"legacy-asset\", \"next-asset\"]' WHERE id = :id"
|
|
), {"id": frame_id})
|
|
|
|
# An orphaned frame_calendars row (this frame's mode was never
|
|
# "calendar", so it has no calendar widget for _ensure_frame_
|
|
# calendars_rekeyed to attach it to) -- exercises that it's dropped
|
|
# cleanly rather than raising.
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
|
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1)"
|
|
), {"frame_id": frame_id, "user_id": user_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"]
|
|
|
|
|
|
def test_migration_28_dedupes_and_enforces_one_action_per_widget_per_button(db_session):
|
|
"""Exercises _migration_28's real ALTER path: a pre-existing database
|
|
with more than one FrameButtonAction row bound to the same (widget,
|
|
button) -- the old frame-level "Button assignments" card allowed
|
|
this -- gets de-duped down to one row (MIN(id) survives) before the
|
|
new unique index is created, rather than the migration failing
|
|
outright on "UNIQUE constraint failed"."""
|
|
frame = db_session.get(Frame, 1)
|
|
widget = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).first()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
# The fresh-install create_all() copy already has the unique
|
|
# index (it reflects models.py's current shape) -- drop it first
|
|
# so the duplicate insert below doesn't just fail immediately,
|
|
# simulating a real pre-migration-28 database.
|
|
conn.execute(text("DROP INDEX IF EXISTS ix_frame_button_actions_widget_button"))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
|
|
"VALUES (:frame_id, 'next', :widget_id, 'back', 1, 0)"
|
|
), {"frame_id": frame.id, "widget_id": widget.id})
|
|
conn.execute(text("UPDATE schema_version SET version = 27"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
actions = db_session.scalars(
|
|
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == "next")
|
|
).all()
|
|
assert len(actions) == 1
|
|
|
|
inspector = inspect(db_module.engine)
|
|
indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
|
assert "ix_frame_button_actions_widget_button" in indexes
|
|
|
|
|
|
def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_session):
|
|
"""Exercises _migration_29's real guarded ALTER path (frames isn't
|
|
dropped/recreated by the pre-widget-system replay tests, so its
|
|
columns must be added defensively, same reasoning as migration
|
|
26/27's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 28"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
frame = db_session.get(Frame, 1)
|
|
assert frame.hold_duration_ms == 3000
|
|
assert frame.next_hold_action is None
|
|
assert frame.back_hold_action is None
|
|
assert frame.last_cycled_layout_id is None
|
|
|
|
|
|
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
|
|
"""Exercises _migration_30's real guarded ALTER path (frames isn't
|
|
dropped/recreated by the pre-widget-system replay tests, so its
|
|
columns must be added defensively, same reasoning as migration
|
|
26/27/29's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 29"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
frame = db_session.get(Frame, 1)
|
|
assert frame.last_displayed_image is None
|
|
assert frame.last_displayed_at == 0.0
|
|
|
|
|
|
def test_migration_40_adds_font_scale_to_an_existing_database(db_session):
|
|
"""Exercises _migration_40's real guarded ALTER path (widgets isn't
|
|
dropped/recreated by the pre-widget-system replay tests, so its
|
|
columns must be added defensively, same reasoning as migration
|
|
26/27/29/30's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 39"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
widget = db_session.query(Widget).filter(Widget.frame_id == 1).first()
|
|
assert widget.font_scale == 1.0
|
|
|
|
|
|
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
|
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
|
SQL back to back (the real "existing widget-system database
|
|
upgrading past both migrations" scenario, since both apply in the
|
|
same run_migrations() call here): a calendar_widget_configs row in
|
|
its pre-17 shape (tasks_* columns still present, still holding a
|
|
configured task source) should come out the other side as a sibling
|
|
`tasks` widget with that source carried forward as its first
|
|
FrameTaskList row (migration 17's extraction, then migration 18's
|
|
further extraction of the single-source TaskWidgetConfig it produces
|
|
into FrameTaskList), with calendar_widget_configs no longer having
|
|
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
|
calendar_key columns either."""
|
|
with db_module.engine.begin() as conn:
|
|
# static_widget_configs/text_widget_configs/saved_layout_*/
|
|
# weather_widget_configs/battery_widget_configs dropped too --
|
|
# see the comment on the identical setup in
|
|
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
|
|
# 20/21/23/24/25's raw CREATE TABLE collides with an already-present table
|
|
# otherwise, since this test replays 17 through 25 and none of
|
|
# these tables would really exist yet at a genuine pre-migration-17
|
|
# schema_version).
|
|
conn.execute(text("DROP TABLE task_widget_configs"))
|
|
conn.execute(text("DROP TABLE frame_task_lists"))
|
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
|
conn.execute(text("DROP TABLE static_widget_configs"))
|
|
conn.execute(text("DROP TABLE text_widget_configs"))
|
|
conn.execute(text("DROP TABLE saved_layout_button_actions"))
|
|
conn.execute(text("DROP TABLE saved_layout_sources"))
|
|
conn.execute(text("DROP TABLE saved_layout_widgets"))
|
|
conn.execute(text("DROP TABLE saved_layouts"))
|
|
conn.execute(text("DROP TABLE weather_widget_configs"))
|
|
conn.execute(text("DROP TABLE battery_widget_configs"))
|
|
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("UPDATE schema_version SET version = 16"))
|
|
|
|
user = make_user(db_session, "task-owner")
|
|
user_id = user.id
|
|
|
|
# Frame #1's auto-migrated widget is a full-panel "photos" one --
|
|
# remove it to free up grid space for the calendar widget below (and
|
|
# for the tasks widget this migration is expected to carve out).
|
|
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
|
db_session.commit()
|
|
|
|
calendar_widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2,
|
|
sort_order=1, created_at=time.time())
|
|
db_session.add(calendar_widget)
|
|
db_session.flush()
|
|
calendar_widget_id = calendar_widget.id
|
|
db_session.commit()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text(
|
|
"INSERT INTO calendar_widget_configs "
|
|
"(widget_id, tasks_enabled, tasks_user_id, tasks_calendar_key, tasks_checked_at, tasks_cached) "
|
|
"VALUES (:widget_id, 1, :user_id, 'caldav:/some/tasks/', 123.0, '[{\"summary\": \"Buy milk\"}]')"
|
|
), {"widget_id": calendar_widget_id, "user_id": user_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]
|
|
|
|
columns = {c["name"] for c in inspect(db_module.engine).get_columns("calendar_widget_configs")}
|
|
assert not any(c.startswith("tasks_") for c in columns)
|
|
task_cfg_columns = {c["name"] for c in inspect(db_module.engine).get_columns("task_widget_configs")}
|
|
assert "user_id" not in task_cfg_columns and "calendar_key" not in task_cfg_columns
|
|
assert "show_completed" in task_cfg_columns
|
|
|
|
task_widgets = db_session.scalars(
|
|
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
|
|
).all()
|
|
assert len(task_widgets) == 1
|
|
task_widget = task_widgets[0]
|
|
|
|
cfg = db_session.get(TaskWidgetConfig, task_widget.id)
|
|
assert cfg.checked_at == 123.0
|
|
assert cfg.cached == [{"summary": "Buy milk"}]
|
|
assert cfg.show_completed is False
|
|
|
|
task_list = db_session.scalars(
|
|
select(FrameTaskList).where(FrameTaskList.widget_id == task_widget.id)
|
|
).one()
|
|
assert task_list.user_id == user_id
|
|
assert task_list.calendar_key == "caldav:/some/tasks/"
|
|
assert task_list.included is True
|
|
|
|
# Auto-placed without overlapping the calendar widget it was split from.
|
|
cal = db_session.get(Widget, calendar_widget_id)
|
|
assert not grid.overlaps((cal.x, cal.y, cal.w, cal.h),
|
|
(task_widget.x, task_widget.y, task_widget.w, task_widget.h))
|
|
|
|
|
|
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
|
|
"""A frame whose mode was "calendar" (not "photos") gets a calendar
|
|
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
|
|
rekeyed should then attach the pre-existing frame_id-keyed
|
|
FrameCalendar row inserted below to that widget's id, not drop it.
|
|
This is the regression case for the ordering bug the two migration
|
|
tests above's setup was designed to catch: the rekey must run AFTER
|
|
the widget backfill, not as a numbered migration racing ahead of
|
|
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
|
with db_module.engine.begin() as conn:
|
|
# static_widget_configs/text_widget_configs/saved_layout_*/
|
|
# weather_widget_configs dropped too -- see the comment on the
|
|
# identical setup in
|
|
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
|
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
|
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
|
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
|
"saved_layout_widgets", "saved_layouts", "weather_widget_configs",
|
|
"battery_widget_configs", "widgets"):
|
|
conn.execute(text(f"DROP TABLE {table}"))
|
|
conn.execute(text("DROP TABLE frame_calendars"))
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
|
))
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text("UPDATE schema_version SET version = 15"))
|
|
|
|
frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
|
|
created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
user = make_user(db_session, "cal-owner")
|
|
db_session.commit()
|
|
frame_id, user_id = frame.id, user.id
|
|
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id})
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) "
|
|
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)"
|
|
), {"frame_id": frame_id, "user_id": user_id})
|
|
|
|
run_migrations()
|
|
|
|
widget = db_session.scalars(
|
|
select(Widget).where(Widget.frame_id == frame_id, Widget.widget_type == "calendar")
|
|
).one()
|
|
row = db_session.scalars(select(FrameCalendar).where(FrameCalendar.widget_id == widget.id)).one()
|
|
assert row.user_id == user_id
|
|
assert row.calendar_key == "ics"
|
|
assert row.calendar_label == "Legacy Cal"
|
|
assert row.included is True
|
|
assert row.color_index == 3
|