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
+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",