Split the tasks feature out of the calendar widget into its own widget type
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s

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:
Thomas Faour
2026-07-25 02:11:45 +00:00
parent 9f3f4b6f62
commit b5c52004c8
25 changed files with 770 additions and 319 deletions
+89 -2
View File
@@ -20,6 +20,7 @@ from app.models import (
FrameCalendar,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
Widget,
WhiteboardWidgetConfig,
)
@@ -179,7 +180,7 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
migrate."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs",
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
@@ -233,6 +234,92 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
assert config.queue == ["legacy-asset", "next-asset"]
def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
"""Exercises _migration_17's actual data-extraction SQL (the real
"existing widget-system database upgrading past this migration"
scenario): 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 carrying that
source, with calendar_widget_configs no longer having tasks_*
columns at all."""
with db_module.engine.begin() as conn:
conn.execute(text("DROP TABLE task_widget_configs"))
conn.execute(text("DROP TABLE calendar_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_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.user_id == user_id
assert cfg.calendar_key == "caldav:/some/tasks/"
assert cfg.checked_at == 123.0
assert cfg.cached == [{"summary": "Buy milk"}]
# 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_
@@ -243,7 +330,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
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:
for table in ("frame_button_actions", "whiteboard_widget_configs",
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
+20 -9
View File
@@ -13,6 +13,7 @@ from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
TaskWidgetConfig,
User,
Widget,
WhiteboardWidgetConfig,
@@ -62,6 +63,16 @@ def _add_calendar_widget(db_session, frame: Frame) -> Widget:
return widget
def _add_tasks_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
# --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session):
@@ -158,7 +169,7 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
def test_tasks_source_set_always_targets_the_caller(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
widget = _add_tasks_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
@@ -167,14 +178,14 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id == bob_row.id
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id == bob_row.id
assert cfg.calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
widget = _add_tasks_widget(db_session, frame)
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
@@ -184,9 +195,9 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id is None
assert cfg.tasks_calendar_key is None
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id is None
assert cfg.calendar_key is None
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
@@ -196,7 +207,7 @@ def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_sessio
assert resp.status_code == 404
def test_tasks_source_400s_when_widget_is_not_a_calendar(client, db_session):
def test_tasks_source_400s_when_widget_is_not_tasks(client, db_session):
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
+22 -4
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import pytest
from PIL import Image
from app.calendar_render import CALENDAR_VIEWS, render_calendar
from app.calendar_render import CALENDAR_VIEWS, render_calendar, render_tasks
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
@@ -55,11 +55,10 @@ def test_calendar_render_size_empty_events():
assert len(data) == EXPECTED_BYTES
def test_calendar_render_size_with_fetch_summary_and_tasks():
tasks = [{"summary": "Buy milk", "completed": False}, {"summary": "Walk the dog", "completed": True}]
def test_calendar_render_size_with_fetch_summary():
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable",
week_days=5, week_layout="vertical", tasks=tasks)
week_days=5, week_layout="vertical")
assert len(data) == EXPECTED_BYTES
@@ -69,6 +68,25 @@ def test_calendar_render_size_with_week_start_offset():
assert len(data) == EXPECTED_BYTES
# --- tasks widget (split out of the calendar widget's old week-view-only task list) ---
_SAMPLE_TASKS = [
{"summary": "Buy milk", "due": "2026-08-02"},
{"summary": "Walk the dog", "due": None},
]
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_tasks_render_size_across_orientations(orientation):
data = render_tasks(_SAMPLE_TASKS, orientation=orientation, palette_rgb=None)
assert len(data) == EXPECTED_BYTES
def test_tasks_render_size_empty():
data = render_tasks([], orientation="landscape", palette_rgb=None)
assert len(data) == EXPECTED_BYTES
# --- render_panel (the widget-system compositor) ---
@@ -66,7 +66,7 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
"calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"},
"calendar_weather_units": "celsius"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
@@ -78,7 +78,6 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
assert cfg.week_layout == "vertical"
assert cfg.weather_enabled is True
assert cfg.weather_units == "celsius"
assert cfg.tasks_enabled is True
def test_config_save_only_partially_updates_provided_fields(client, db_session):
+1 -19
View File
@@ -25,13 +25,11 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
return frame, widget
def _stub_fetches(monkeypatch, events=None, weather=None, tasks=None):
def _stub_fetches(monkeypatch, events=None, weather=None):
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: (events or [], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: weather or [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: tasks or [])
def test_render_produces_a_correctly_sized_image(db_session, monkeypatch):
@@ -93,27 +91,11 @@ def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: calls.append(1) or [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [])
widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == []
def test_tasks_only_fetched_for_week_view_when_enabled(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda", tasks_enabled=True) # not week view
calls = []
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: calls.append(1) or [])
widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == [] # agenda view -- tasks never shown, so never fetched
def test_advance_action_increments_browse_offset(db_session):
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
+72
View File
@@ -0,0 +1,72 @@
"""app.widgets.tasks -- unit-level, no HTTP: constructs Widget/
TaskWidgetConfig rows directly and monkeypatches the underlying fetch
call (get_or_refresh_tasks_for_widget, throttle/CalDAV-fetch logic
covered separately). Split out of the old calendar widget's week-view-
only task list -- see models.TaskWidgetConfig."""
from __future__ import annotations
import time
from app import widgets
from app.models import Frame, TaskWidgetConfig, Widget
from .conftest import make_user
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return frame, widget
def _make_configured_widget(db_session) -> tuple[Frame, Widget]:
user = make_user(db_session, "task-owner")
return _make_widget(db_session, user_id=user.id, calendar_key="caldav:/some/tasks/")
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session):
frame, widget = _make_widget(db_session) # no user_id/calendar_key set
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_render_configured_widget_produces_a_correctly_sized_image(db_session, monkeypatch):
frame, widget = _make_configured_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [{"summary": "Buy milk", "due": None}])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_configured_but_empty_task_list_still_renders(db_session, monkeypatch):
frame, widget = _make_configured_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
"""grid.MIN_FOOTPRINT["tasks"] is (2, 2) cells -- on an 8x5 grid
against a full 800x480 panel that's a 200x192 box, the smallest a
tasks widget can actually be placed at."""
frame, widget = _make_configured_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
img = widgets.tasks.render(db_session, frame, widget, 200, 192)
assert img.size == (200, 192)
def test_no_button_actions():
"""A passive checklist on the same throttled-refresh cadence as
weather -- nothing to advance/back/force."""
assert widgets.tasks.ACTIONS == {}
assert widgets.tasks.ACTION_LABELS == {}