Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist tied to a calendar's view and only inside its footprint. Tasks are now a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that can be placed and sized independently, same as photos/calendar/ whiteboard -- no separate "enabled" flag either, since being on the grid at all is the on/off switch, matching every other widget type. Migration 17 creates task_widget_configs, extracts any existing calendar widget's configured task source into a new sibling tasks widget (auto-placed in open grid space, source dropped+logged if truly none is left), then drops calendar_widget_configs' now-dead tasks_* columns in the same migration -- this project's usual same-migration- drop convention. Also handles the rarer case of a database jumping straight from before the widget system existed to after this split in one boot, via the legacy Frame.calendar_tasks_* columns. Verified live in the browser at desktop and mobile widths: adding a Tasks widget, its own dialog (task-list source picker + preview), and confirming the calendar widget's dialog no longer mentions tasks at all. Full test suite (180 tests, including new coverage for the widget render/actions, the migration's data-extraction path, and the permission-boundary shape for tasks-source) passes.
This commit is contained in:
+158
-5
@@ -27,6 +27,7 @@ from .models import (
|
||||
FrameButtonAction,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
TaskWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
@@ -333,6 +334,110 @@ def _migration_16(conn) -> None:
|
||||
))
|
||||
|
||||
|
||||
def _migration_17(conn) -> None:
|
||||
"""Splits the calendar widget's old week-view-only task list out into
|
||||
its own standalone widget type (see models.TaskWidgetConfig,
|
||||
app/widgets/tasks.py) -- a task list is no longer tied to a
|
||||
calendar's view or footprint, and can be placed/sized on its own.
|
||||
|
||||
Every calendar_widget_configs row that still has a task source
|
||||
configured gets a new sibling `tasks` widget carrying that source
|
||||
over, auto-placed in whatever open grid space is left on its frame
|
||||
(same find_open_rect logic a manual "add widget" uses; if truly none
|
||||
is left, the source is dropped and logged -- rare enough, and with
|
||||
no interactive way to ask during a boot-time migration, that this is
|
||||
an acceptable edge case). calendar_widget_configs then drops its now
|
||||
-dead tasks_* columns -- this project's usual same-migration-drop
|
||||
convention (see docs/widgets.md's Known Gaps for the one deliberate,
|
||||
much-larger-blast-radius exception)."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE task_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||
"calendar_key TEXT, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached TEXT)"
|
||||
))
|
||||
|
||||
rows = conn.execute(text(
|
||||
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
|
||||
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
|
||||
"FROM calendar_widget_configs cwc "
|
||||
"JOIN widgets w ON w.id = cwc.widget_id "
|
||||
"JOIN frames f ON f.id = w.frame_id "
|
||||
"WHERE cwc.tasks_calendar_key IS NOT NULL"
|
||||
)).mappings().all()
|
||||
|
||||
skipped = 0
|
||||
now = time.time()
|
||||
for row in rows:
|
||||
existing = conn.execute(text(
|
||||
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
|
||||
), {"frame_id": row["frame_id"]}).all()
|
||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
|
||||
if rect is None:
|
||||
skipped += 1
|
||||
continue
|
||||
x, y, w, h = rect
|
||||
max_sort = conn.execute(text(
|
||||
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
||||
), {"frame_id": row["frame_id"]}).scalar()
|
||||
result = conn.execute(text(
|
||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
|
||||
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
|
||||
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
||||
"sort_order": max_sort + 1, "created_at": now})
|
||||
new_widget_id = result.lastrowid
|
||||
conn.execute(text(
|
||||
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
|
||||
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
|
||||
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
|
||||
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
|
||||
"cached": row["tasks_cached"]})
|
||||
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"%d calendar widget(s) had a task list configured but no open grid space for a "
|
||||
"standalone tasks widget -- their task source was dropped", skipped
|
||||
)
|
||||
|
||||
# Rebuild calendar_widget_configs without the now-dead tasks_*
|
||||
# columns -- SQLite can't drop tasks_user_id directly (it's part of
|
||||
# an FK constraint), same situation frame_calendars hit in
|
||||
# _migration_9, same rebuild-create-copy-drop-rename fix.
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs_new ("
|
||||
"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)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO calendar_widget_configs_new "
|
||||
"(widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
||||
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
||||
"week_days, week_layout, week_start_offset) "
|
||||
"SELECT widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
||||
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
||||
"week_days, week_layout, week_start_offset "
|
||||
"FROM calendar_widget_configs"
|
||||
))
|
||||
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -350,6 +455,7 @@ MIGRATIONS = [
|
||||
(14, _migration_14),
|
||||
(15, _migration_15),
|
||||
(16, _migration_16),
|
||||
(17, _migration_17),
|
||||
]
|
||||
|
||||
|
||||
@@ -518,11 +624,26 @@ def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetC
|
||||
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,
|
||||
# tasks_* deliberately not carried over -- see _task_config_from_frame,
|
||||
# a sibling standalone widget now, not part of this config.
|
||||
)
|
||||
|
||||
|
||||
def _task_config_from_frame(frame: Frame, widget_id: int) -> TaskWidgetConfig:
|
||||
"""Only ever called for a frame whose legacy calendar_tasks_* columns
|
||||
(see Frame's own docstring on those -- a dead pre-widget-system
|
||||
field set, same status as calendar_photo_inlay below) still carry a
|
||||
configured source -- i.e. a database jumping straight from before
|
||||
the widget system existed to after tasks became its own widget type
|
||||
in a single upgrade, skipping the intermediate period where it would
|
||||
have lived on CalendarWidgetConfig instead (see _migration_17's own
|
||||
extraction of *that* case)."""
|
||||
return TaskWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
user_id=frame.calendar_tasks_user_id,
|
||||
calendar_key=frame.calendar_tasks_calendar_key,
|
||||
checked_at=frame.calendar_tasks_checked_at,
|
||||
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -556,6 +677,33 @@ def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) ->
|
||||
]
|
||||
|
||||
|
||||
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
||||
"""Only relevant for a database jumping straight from before the
|
||||
widget system existed to after tasks became its own widget type in
|
||||
one upgrade (see _task_config_from_frame) -- frame.calendar_tasks_*
|
||||
is the dead legacy field set otherwise. Auto-placed in whatever open
|
||||
space is left after the widget(s) above it in _backfill_frame_
|
||||
widgets claimed theirs, same find_open_rect logic a manual "add
|
||||
widget" uses; silently dropped (logged) if none fits, same as this
|
||||
migration having nowhere else to put it either."""
|
||||
if not frame.calendar_tasks_calendar_key:
|
||||
return
|
||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
|
||||
if rect is None:
|
||||
logger.warning(
|
||||
"Frame %d had a legacy task list configured but no open grid space for a "
|
||||
"standalone tasks widget during backfill -- its task source was dropped", frame.id
|
||||
)
|
||||
return
|
||||
x, y, w, h = rect
|
||||
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
||||
sort_order=next_sort_order, created_at=time.time())
|
||||
db.add(task_widget)
|
||||
db.flush()
|
||||
db.add(_task_config_from_frame(frame, task_widget.id))
|
||||
|
||||
|
||||
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"
|
||||
@@ -575,6 +723,9 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
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"))
|
||||
_maybe_add_legacy_tasks_widget(
|
||||
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
||||
)
|
||||
return
|
||||
|
||||
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
||||
@@ -588,6 +739,8 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
elif mode == "whiteboard":
|
||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
||||
if mode == "calendar":
|
||||
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
||||
|
||||
|
||||
def _ensure_widgets_backfilled() -> None:
|
||||
|
||||
Reference in New Issue
Block a user