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:
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in New Issue
Block a user