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
+41 -27
View File
@@ -1,8 +1,8 @@
"""Everything scoped to one specific widget rather than "the frame":
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
plus every setting/action that used to assume a frame had at most one
widget of a given type -- photo queue, calendar inclusion/color/tasks,
whiteboard source, and their preview endpoints. Split out of
widget of a given type -- photo queue, calendar inclusion/color, tasks
source, whiteboard source, and their preview endpoints. Split out of
api_frames.py (which keeps frame-wide settings: orientation, quiet
hours, palette, firmware, stats) once a frame could hold more than one
widget of the same type, at which point "the frame's calendar settings"
@@ -35,6 +35,7 @@ from ..models import (
Frame,
FrameCalendar,
PhotoWidgetConfig,
TaskWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
@@ -254,7 +255,6 @@ def api_widget_config_save(
calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | None = Form(None),
calendar_tasks_enabled: bool | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -317,8 +317,6 @@ def api_widget_config_save(
# new unit label.
ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units
if calendar_tasks_enabled is not None:
ccfg.tasks_enabled = calendar_tasks_enabled
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -506,7 +504,7 @@ def api_widget_preview_rendered(
return Response(content=png, media_type="image/png")
# --- Calendar: inclusion/color/tasks/weather/preview --------------------
# --- Calendar: inclusion/color/weather/preview ---------------------------
class CalendarSelectRequest(BaseModel):
user_id: int
@@ -609,21 +607,37 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (ccfg.view == "week" and ccfg.tasks_enabled) else None
)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset,
)
return Response(content=png, media_type="image/png")
# --- Tasks: source/preview ------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
def api_widget_preview_tasks(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The same cached task list a live device render would use, same
"reflects what's currently saved" convention as the other preview
endpoints."""
frame, widget = frame_widget
_require_widget_type(widget, "tasks")
tcfg = db.get(TaskWidgetConfig, widget.id)
if not tcfg.calendar_key or not tcfg.user_id:
raise HTTPException(400, "No task list configured on this widget yet")
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb)
return Response(content=png, media_type="image/png")
class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source
@@ -634,27 +648,27 @@ def api_widget_tasks_source(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Points this widget's week-view task list at one of the calling
user's own CalDAV calendars -- same owner-controls-their-own-data
permission split as calendar-select's included=True, since this is
volunteering personal calendar data, not a display setting a
controller should get to pick on someone else's behalf. None clears
the source; clearing (unlike setting) isn't ownership-gated -- like
muting a shared calendar, anyone linked to the frame can turn off a
task list they'd rather not see, but only its owner can point the
widget at one of their calendars to begin with."""
"""Points this tasks widget at one of the calling user's own CalDAV
calendars -- same owner-controls-their-own-data permission split as
calendar-select's included=True, since this is volunteering personal
calendar data, not a display setting a controller should get to pick
on someone else's behalf. None clears the source; clearing (unlike
setting) isn't ownership-gated -- like muting a shared calendar,
anyone linked to the frame can turn off a task list they'd rather
not see, but only its owner can point the widget at one of their
calendars to begin with."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
_require_widget_type(widget, "tasks")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.calendar_key is None:
cfg.tasks_user_id = None
cfg.tasks_calendar_key = None
cfg.tasks_cached = None
cfg.user_id = None
cfg.calendar_key = None
cfg.cached = None
else:
cfg.tasks_user_id = user.id
cfg.tasks_calendar_key = body.calendar_key
cfg.tasks_checked_at = 0.0 # pick up the change promptly
cfg.user_id = user.id
cfg.calendar_key = body.calendar_key
cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key}