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
+21 -15
View File
@@ -1,9 +1,10 @@
# Widget system # Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard), like arranging icons on placed/sized widgets (photos/calendar/whiteboard/tasks), like arranging
an Android home screen. A frame can hold several widgets of the same type icons on an Android home screen. A frame can hold several widgets of the
(e.g. two photo widgets pointed at different Immich albums side by side). same type (e.g. two photo widgets pointed at different Immich albums side
by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -19,17 +20,21 @@ a button press does.
## Data model ## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"`), `x`/`y`/`w`/`h` (grid (`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"`), `x`/`y`/`w`/`h`
cells), `sort_order`. Widgets never overlap (enforced server-side in (grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client `routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order, already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas. no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, each keyed by `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`widget_id` with `ondelete="CASCADE"` -- rather than one wide table with each keyed by `widget_id` with `ondelete="CASCADE"` -- rather than one
every type's mostly-irrelevant columns. `PhotoWidgetConfig` mirrors wide table with every type's mostly-irrelevant columns. `PhotoWidgetConfig`
`app/photo_queue.py`'s attribute names exactly, so that module's mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged. advance/back/queue logic ports across widget instances unchanged.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only task list); split into its own
widget type (migration 17) so a task list can be placed and sized
independent of any calendar's view/footprint.
- `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame - `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame
can now have more than one independent calendar widget, each with its can now have more than one independent calendar widget, each with its
own set of included calendars. own set of included calendars.
@@ -50,16 +55,16 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2. Enforced both client-side (UX, in the size-tier scaling), whiteboard 2x2, tasks 2x2. Enforced both client-side
Layout tab's drag/resize canvas -- `static/frame_layout.js`) and (UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
server-side (`routers/api_widgets.py`) -- the client is never trusted and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone. alone.
## Rendering: one shared compositor ## Rendering: one shared compositor
`app/widgets/` is the render/action registry -- one module per `app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`), each `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`),
exposing: each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`: - `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -69,7 +74,8 @@ exposing:
bad moment doesn't blank the whole panel. bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type - `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). for whiteboard). Empty for tasks -- a passive checklist on the same
throttled-refresh cadence as weather, nothing to advance/back/force.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button- - `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI. assignment UI.
+78 -57
View File
@@ -1,7 +1,11 @@
"""Renders calendar frame mode's three views (agenda/week/month) into the """Renders calendar frame mode's three views (agenda/week/month), and the
panel's packed format, following image_pipeline.render_placeholder's own separate standalone tasks widget (see models.TaskWidgetConfig -- a task
precedent: build an RGB canvas with ImageDraw/ImageFont, then the same list used to be a calendar-widget-only week-view slot, split out into
_quantize/_transpose_and_pack every other renderer ends on. its own widget type so it isn't tied to a calendar's view/footprint),
into the panel's packed format, following image_pipeline.
render_placeholder's own precedent: build an RGB canvas with
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end" Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name", (ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
@@ -523,33 +527,24 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int], def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont, tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont) -> None:
margin: int = MARGIN) -> None:
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box """A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
glyph + due date (if any) + summary per outstanding task, same glyph + due date (if any) + summary per outstanding task, same
header/rule/row-cap/truncation shape as _draw_agenda_day's event header/rule/row-cap/truncation shape as _draw_agenda_day's event
list so the "week view, one slot replaced by tasks instead of a day" list so the standalone tasks widget (see _build_tasks) reads as the
layout (see _build_week) reads as one consistent design rather than same consistent design as everything else on-panel, not a
two different widgets bolted together. Reuses _draw_mixed_line so a bolted-together look. Reuses _draw_mixed_line so a task summary with
task summary with emoji in it renders the same way an event emoji in it renders the same way an event title's does."""
title's does.
`margin` defaults to the module-wide MARGIN (vertical layout's
stacked bands are as wide as the whole content region, same as
_draw_agenda_day's own sections) but a narrow horizontal-layout
column passes a much smaller one -- MARGIN on both sides of an
already-cramped ~150px week column left almost nothing for the
title text itself."""
x0, y0, w, h = region x0, y0, w, h = region
text_x0, text_y0 = x0 + margin, y0 + margin text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - margin * 2 text_w = w - MARGIN * 2
draw_text(img, (text_x0, text_y0), "Tasks", title_font) draw_text(img, (text_x0, text_y0), "Tasks", title_font)
y = text_y0 + title_font.size + 12 y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12 y += 12
row_h = body_font.size + 14 row_h = body_font.size + 14
max_rows = max(0, (y0 + h - margin - y) // row_h) max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks: if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED) draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
@@ -642,16 +637,13 @@ _WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small"
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None, days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image: start_offset: int = 0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side """`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing generalized) or stacked bands (layout="vertical", reusing
_draw_agenda_day the same way _build_today_tomorrow does, just for _draw_agenda_day the same way _build_today_tomorrow does, just for
an arbitrary day count instead of a hardcoded 2). `tasks` (see an arbitrary day count instead of a hardcoded 2).
routers/common.py's get_or_refresh_tasks), if not None, takes the
LAST slot instead of adding an extra one -- "N days" always means N
slots total, whether they're all days or N-1 days plus a task list.
At the default 7 days, the view anchors to week_start (a fixed At the default 7 days, the view anchors to week_start (a fixed
weekday, "start on the most recent Monday") exactly like before -- weekday, "start on the most recent Monday") exactly like before --
@@ -668,7 +660,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset) week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else: else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset) week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
day_count = days - 1 if tasks is not None else days
owners_seen: list[str] = [] owners_seen: list[str] = []
if layout == "vertical": if layout == "vertical":
@@ -677,7 +668,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
body_font = ImageFont.load_default(size=max(11, body_base - days)) body_font = ImageFont.load_default(size=max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days)) weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // days section_h = target_h // days
for i in range(day_count): for i in range(days):
section_y0 = i * section_h section_y0 = i * section_h
if i > 0: if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE) draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
@@ -685,11 +676,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h), _draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
title_font, body_font, owners_seen, palette_rgb, title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units) weather_cities, weather_font, weather_units)
if tasks is not None:
section_y0 = day_count * section_h
if day_count > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
_draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
return img return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier] header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
@@ -699,7 +685,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
col_w = (target_w - MARGIN * 2) // days col_w = (target_w - MARGIN * 2) // days
header_h = 44 header_h = 44
for col in range(day_count): for col in range(days):
day = week_first_day + timedelta(days=col) day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w x0 = MARGIN + col * col_w
if col > 0: if col > 0:
@@ -735,13 +721,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
chip_font, col_w - 20 - prefix_w) chip_font, col_w - 20 - prefix_w)
y += row_h y += row_h
if tasks is not None:
col = day_count
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
_draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
return img return img
@@ -814,7 +793,7 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str, def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
fetch_summary: str, week_start: int, palette_rgb: list | None = None, fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None, week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image: week_start_offset: int = 0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view effective_view = view
@@ -829,15 +808,14 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
weather_cities, weather_units) weather_cities, weather_units)
elif effective_view == "week": elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset) weather_cities, weather_units, week_days, week_layout, week_start_offset)
elif effective_view == "month": elif effective_view == "month":
# Never given weather or tasks -- no room for either at typical # Never given weather -- no room for it at typical month-cell
# month-cell size, same reasoning that already keeps this view # size, same reasoning that already keeps this view to density
# to density dots instead of literal event text (see # dots instead of literal event text (see _build_month's own
# _build_month's own docstring). Colors are still passed # docstring). Colors are still passed through, though -- that's
# through, though -- that's a different concern (legibility of # a different concern (legibility of individual events) than
# individual events) than needing a whole extra strip/slot of # needing a whole extra strip of content.
# content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb) img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
else: else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
@@ -855,18 +833,16 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", week_days: int = 7, week_layout: str = "horizontal",
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes: week_start_offset: int = 0) -> bytes:
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed """Renders one of CALENDAR_VIEWS full-panel to the panel's packed
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
invariant every other renderer honors. weather_cities is invariant every other renderer honors. weather_cities is
routers/common.py's get_or_refresh_weather() cache, or None/[] to routers/common.py's get_or_refresh_weather() cache, or None/[] to
omit the weather strip entirely (also always omitted for view == omit the weather strip entirely (also always omitted for view ==
"month"). tasks is get_or_refresh_tasks()'s cache, or None to omit "month")."""
the task list entirely -- only ever drawn for view == "week", see
_build_week."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start, img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset) palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation) return _transpose_and_pack(quantized, orientation)
@@ -877,13 +853,58 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", week_days: int = 7, week_layout: str = "horizontal",
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes: week_start_offset: int = 0) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable """Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame.""" image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start, img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset) palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
# --- Standalone tasks widget (split out of the old calendar-widget-only
# week-view task list -- see models.TaskWidgetConfig) -----------------
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font)
return img
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None) -> bytes:
"""Renders the tasks widget full-panel to the panel's packed format.
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
every other renderer honors."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+4 -3
View File
@@ -92,9 +92,10 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]: def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
"""Same lock/refresh/commit dance as frame_locked, additionally """Same lock/refresh/commit dance as frame_locked, additionally
resolving and refreshing the widget's own per-type config row resolving and refreshing the widget's own per-type config row
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see (PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame* TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
granularity -- the exact same per-frame threading.Lock frame_locked still locks at *frame* granularity -- the exact same per-frame
threading.Lock frame_locked
uses, not a separate per-widget lock -- simplest, avoids a new class uses, not a separate per-widget lock -- simplest, avoids a new class
of multi-lock deadlock bugs, and this project's actual concurrency of multi-lock deadlock bugs, and this project's actual concurrency
needs are tiny (a handful of users per household frame). needs are tiny (a handful of users per household frame).
+4 -1
View File
@@ -22,11 +22,14 @@ GRID_SHORT = 5
# the placement UI and server-side (routers/api_widgets.py). A calendar # the placement UI and server-side (routers/api_widgets.py). A calendar
# widget crammed into 1x1 would be illegible regardless of size-tier # widget crammed into 1x1 would be illegible regardless of size-tier
# scaling (see calendar_render.py); whiteboard needs enough room to be # scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell. # worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary
# without truncating on every row.
MIN_FOOTPRINT: dict[str, tuple[int, int]] = { MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1), "photos": (1, 1),
"calendar": (3, 2), "calendar": (3, 2),
"whiteboard": (2, 2), "whiteboard": (2, 2),
"tasks": (2, 2),
} }
Rect = tuple[int, int, int, int] # (x, y, w, h) Rect = tuple[int, int, int, int] # (x, y, w, h)
+158 -5
View File
@@ -27,6 +27,7 @@ from .models import (
FrameButtonAction, FrameButtonAction,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
TaskWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
Widget, 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 = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -350,6 +455,7 @@ MIGRATIONS = [
(14, _migration_14), (14, _migration_14),
(15, _migration_15), (15, _migration_15),
(16, _migration_16), (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_days=frame.calendar_week_days,
week_layout=frame.calendar_week_layout, week_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset, week_start_offset=frame.calendar_week_start_offset,
tasks_enabled=frame.calendar_tasks_enabled, # tasks_* deliberately not carried over -- see _task_config_from_frame,
tasks_user_id=frame.calendar_tasks_user_id, # a sibling standalone widget now, not part of this config.
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,
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: def _backfill_frame_widgets(db, frame: Frame) -> None:
cols, rows = grid.grid_dims(frame.orientation) cols, rows = grid.grid_dims(frame.orientation)
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos" 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(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id)) db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar")) 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 return
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows, 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": elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id)) db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode)) 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: def _ensure_widgets_backfilled() -> None:
+37 -11
View File
@@ -407,7 +407,7 @@ class Widget(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
x: Mapped[int] = mapped_column(Integer) x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer) w: Mapped[int] = mapped_column(Integer)
@@ -450,9 +450,12 @@ class CalendarWidgetConfig(Base):
fields that used to live as calendar_* columns directly on Frame, fields that used to live as calendar_* columns directly on Frame,
minus calendar_photo_inlay (dropped: arbitrary widget placement minus calendar_photo_inlay (dropped: arbitrary widget placement
subsumes what a fixed 50/50 inlay split did, so it's not a special subsumes what a fixed 50/50 inlay split did, so it's not a special
case anymore, just place a photo widget alongside). "Included case anymore, just place a photo widget alongside) and minus
calendars" is its own table (FrameCalendar), widget_id-keyed so each tasks_* (also dropped: split out into its own standalone widget
calendar widget on a frame has its own independent set.""" type, see TaskWidgetConfig, so a task list isn't tied to a
calendar's week view/footprint anymore). "Included calendars" is
its own table (FrameCalendar), widget_id-keyed so each calendar
widget on a frame has its own independent set."""
__tablename__ = "calendar_widget_configs" __tablename__ = "calendar_widget_configs"
@@ -471,13 +474,35 @@ class CalendarWidgetConfig(Base):
week_days: Mapped[int] = mapped_column(Integer, default=7) week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal") week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0) week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
tasks_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True class TaskWidgetConfig(Base):
) """One tasks widget's settings + cached-fetch state -- split out of
tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True) CalendarWidgetConfig (which used to carry these as tasks_* columns,
tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0) a week-view-only task list bolted onto a calendar widget) so a task
tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) list can be placed and sized on its own, independent of any
calendar's view/footprint. No separate "enabled" flag -- unlike the
old bolted-on version, the widget's mere presence on the grid is the
on/off switch, same as every other widget type.
CalDAV only (a task list is a VTODO collection, not something a
plain ICS subscription meaningfully has); source is one specific
linked user's own CalDAV calendar, same owner-controls-their-own-
data permission split as FrameCalendar. user_id SET NULL on the
user's deletion clears the source rather than leaving a dangling
reference (checked_at isn't reset by that, but the next refresh
attempt finds no source and just returns [])."""
__tablename__ = "task_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
# already filtered to outstanding (not-completed) tasks and sorted
# by due date -- see caldav_client.fetch_tasks.
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
class WhiteboardWidgetConfig(Base): class WhiteboardWidgetConfig(Base):
@@ -500,6 +525,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"photos": PhotoWidgetConfig, "photos": PhotoWidgetConfig,
"calendar": CalendarWidgetConfig, "calendar": CalendarWidgetConfig,
"whiteboard": WhiteboardWidgetConfig, "whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig,
} }
+41 -27
View File
@@ -1,8 +1,8 @@
"""Everything scoped to one specific widget rather than "the frame": """Everything scoped to one specific widget rather than "the frame":
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js) 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 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, widget of a given type -- photo queue, calendar inclusion/color, tasks
whiteboard source, and their preview endpoints. Split out of source, whiteboard source, and their preview endpoints. Split out of
api_frames.py (which keeps frame-wide settings: orientation, quiet api_frames.py (which keeps frame-wide settings: orientation, quiet
hours, palette, firmware, stats) once a frame could hold more than one 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" widget of the same type, at which point "the frame's calendar settings"
@@ -35,6 +35,7 @@ from ..models import (
Frame, Frame,
FrameCalendar, FrameCalendar,
PhotoWidgetConfig, PhotoWidgetConfig,
TaskWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS, WIDGET_CONFIG_MODELS,
Widget, Widget,
@@ -254,7 +255,6 @@ def api_widget_config_save(
calendar_week_start_offset: int | None = Form(None), calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None), calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | 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 """Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one 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. # new unit label.
ccfg.weather_checked_at = 0.0 ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units 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: with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -506,7 +504,7 @@ def api_widget_preview_rendered(
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
# --- Calendar: inclusion/color/tasks/weather/preview -------------------- # --- Calendar: inclusion/color/weather/preview ---------------------------
class CalendarSelectRequest(BaseModel): class CalendarSelectRequest(BaseModel):
user_id: int user_id: int
@@ -609,21 +607,37 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id) ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) 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 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( png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
week_start=ccfg.week_start, week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units, 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, week_start_offset=ccfg.week_start_offset,
) )
return Response(content=png, media_type="image/png") 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): class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source 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), frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Points this widget's week-view task list at one of the calling """Points this tasks widget at one of the calling user's own CalDAV
user's own CalDAV calendars -- same owner-controls-their-own-data calendars -- same owner-controls-their-own-data permission split as
permission split as calendar-select's included=True, since this is calendar-select's included=True, since this is volunteering personal
volunteering personal calendar data, not a display setting a calendar data, not a display setting a controller should get to pick
controller should get to pick on someone else's behalf. None clears on someone else's behalf. None clears the source; clearing (unlike
the source; clearing (unlike setting) isn't ownership-gated -- like setting) isn't ownership-gated -- like muting a shared calendar,
muting a shared calendar, anyone linked to the frame can turn off a anyone linked to the frame can turn off a task list they'd rather
task list they'd rather not see, but only its owner can point the not see, but only its owner can point the widget at one of their
widget at one of their calendars to begin with.""" calendars to begin with."""
frame, widget = frame_widget frame, widget = frame_widget
_require_widget_type(widget, "calendar") _require_widget_type(widget, "tasks")
user = require_user_api(request, db) user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg): with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.calendar_key is None: if body.calendar_key is None:
cfg.tasks_user_id = None cfg.user_id = None
cfg.tasks_calendar_key = None cfg.calendar_key = None
cfg.tasks_cached = None cfg.cached = None
else: else:
cfg.tasks_user_id = user.id cfg.user_id = user.id
cfg.tasks_calendar_key = body.calendar_key cfg.calendar_key = body.calendar_key
cfg.tasks_checked_at = 0.0 # pick up the change promptly cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key} return {"status": "saved", "calendar_key": body.calendar_key}
+17 -16
View File
@@ -27,6 +27,7 @@ from ..models import (
FrameButtonAction, FrameButtonAction,
FrameCalendar, FrameCalendar,
PhotoWidgetConfig, PhotoWidgetConfig,
TaskWidgetConfig,
User, User,
Widget, Widget,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
@@ -566,33 +567,33 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]: def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same """Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
cadence as event merging), reading/writing CalendarWidgetConfig (see cadence as event merging), reading/writing TaskWidgetConfig (see
app/widgets/calendar.py). [] if tasks are off, no source is set, or app/widgets/tasks.py). [] if no source is set, or the source user's
the source user's CalDAV credentials/calendar_key have gone missing CalDAV credentials/calendar_key have gone missing (e.g. they
(e.g. they unlinked their account). A refetch failure keeps the unlinked their account). A refetch failure keeps the last-known list
last-known list rather than going blank for one bad cycle, same rather than going blank for one bad cycle, same reasoning as
reasoning as get_or_refresh_weather_for_widget.""" get_or_refresh_weather_for_widget."""
cfg = db.get(CalendarWidgetConfig, widget.id) cfg = db.get(TaskWidgetConfig, widget.id)
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id: if not cfg.calendar_key or not cfg.user_id:
return [] return []
now = time.time() now = time.time()
if cfg.tasks_cached is not None and now - cfg.tasks_checked_at < calendar_feed.CHECK_INTERVAL_S: if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.tasks_cached return cfg.cached
user = db.get(User, cfg.tasks_user_id) user = db.get(User, cfg.user_id)
key = cfg.tasks_calendar_key key = cfg.calendar_key
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"): if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
return cfg.tasks_cached or [] return cfg.cached or []
href = key[len("caldav:"):] href = key[len("caldav:"):]
try: try:
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password) tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
except caldav_client.CalDavError as e: except caldav_client.CalDavError as e:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e) logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
return cfg.tasks_cached or [] return cfg.cached or []
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg): with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.tasks_cached = tasks locked_cfg.cached = tasks
locked_cfg.tasks_checked_at = now locked_cfg.checked_at = now
return tasks return tasks
+19 -12
View File
@@ -31,6 +31,7 @@ from ..models import (
Frame, Frame,
FrameCalendar, FrameCalendar,
PhotoWidgetConfig, PhotoWidgetConfig,
TaskWidgetConfig,
User, User,
UserFrame, UserFrame,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
@@ -136,22 +137,22 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe
return result return result
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None: def _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this widget's week-view task list currently """Whose CalDAV calendar this tasks widget currently pulls from, and
pulls from, and its label -- for showing "using <name>'s Chores its label -- for showing "using <name>'s Chores list" to everyone
list" to everyone linked, not just whoever set it. None if no linked, not just whoever set it. None if no source is configured."""
source is configured.""" if not task_cfg.user_id or not task_cfg.calendar_key:
if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
return None return None
user = db.get(User, calendar_cfg.tasks_user_id) user = db.get(User, task_cfg.user_id)
if user is None: if user is None:
return None return None
label = calendar_cfg.tasks_calendar_key label = task_cfg.calendar_key
for c in (user.calendar_caldav_calendars or []): for c in (user.calendar_caldav_calendars or []):
if f"caldav:{c['href']}" == calendar_cfg.tasks_calendar_key: if f"caldav:{c['href']}" == task_cfg.calendar_key:
label = c.get("display_name") or label label = c.get("display_name") or label
break break
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label} return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label,
"calendar_key": task_cfg.calendar_key}
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None: def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
@@ -195,7 +196,6 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
if widget.widget_type == "calendar": if widget.widget_type == "calendar":
calendar_cfg = db.get(CalendarWidgetConfig, widget.id) calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
return templates.TemplateResponse("_widget_dialog_calendar.html", { return templates.TemplateResponse("_widget_dialog_calendar.html", {
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user, "request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
"calendar_views": CALENDAR_VIEW_LABELS, "calendar_views": CALENDAR_VIEW_LABELS,
@@ -204,8 +204,15 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_color_labels": PALETTE_LABELS, "calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB, "default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex, "palette_to_hex": palette_to_hex,
})
if widget.widget_type == "tasks":
task_cfg = db.get(TaskWidgetConfig, widget.id)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
return templates.TemplateResponse("_widget_dialog_tasks.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"viewer_task_calendars": viewer_task_calendars, "viewer_task_calendars": viewer_task_calendars,
"tasks_source": _tasks_source_info(db, calendar_cfg), "tasks_source": _tasks_source_info(db, task_cfg),
}) })
if widget.widget_type == "whiteboard": if widget.widget_type == "whiteboard":
+1 -1
View File
@@ -60,7 +60,7 @@
// Shared display names for widget_type, everywhere one shows up in the // Shared display names for widget_type, everywhere one shows up in the
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' }; const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks' };
function showStatus(ok, message) { function showStatus(ok, message) {
// While a <dialog> is open, its own .dialog-result container gets the // While a <dialog> is open, its own .dialog-result container gets the
+9 -3
View File
@@ -280,12 +280,18 @@ window.addEventListener('resize', () => {
// a frame can now have several widgets of the same type, so "the // a frame can now have several widgets of the same type, so "the
// Calendar tab" stopped meaning anything unambiguous. // Calendar tab" stopped meaning anything unambiguous.
// widget_dialog_{photos,calendar,whiteboard}.js each define an // widget_dialog_{photos,calendar,whiteboard,tasks}.js each define an
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by // init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
// frame_layout.html, since which one runs depends on which widget's gear // frame_layout.html, since which one runs depends on which widget's gear
// icon was clicked). // icon was clicked).
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog }; const DIALOG_INIT = {
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog }; photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog,
};
let openDialogWidgetType = null; let openDialogWidgetType = null;
+1 -87
View File
@@ -1,5 +1,5 @@
// Calendar widget dialog: view/week-start settings, per-user opt-in, // Calendar widget dialog: view/week-start settings, per-user opt-in,
// weather, tasks, and the rendered preview. Not a page-load script -- // weather, and the rendered preview. Not a page-load script --
// frame_layout.js fetches this widget's dialog HTML fragment, injects // frame_layout.js fetches this widget's dialog HTML fragment, injects
// it into the shared <dialog>, points window.FRAME_API at this specific // it into the shared <dialog>, points window.FRAME_API at this specific
// widget (/api/frames/{id}/widgets/{widget_id}), then calls // widget (/api/frames/{id}/widgets/{widget_id}), then calls
@@ -64,48 +64,6 @@ async function removeWeatherCity(e) {
} }
} }
// Rewrites #tasks-current-source in place instead of telling the user
// to reload -- the API always assigns a successful "set" to the caller
// (see api_widget_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) return; // matches the template's no-tasks_source branch: nothing rendered
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadCalendarPreview() { function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
} }
@@ -237,50 +195,6 @@ function initCalendarDialog() {
} }
}); });
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
const el = e.target;
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
// Choosing one of your own CalDAV task lists as this widget's source --
// owner-only (see api_widget_tasks_source), so these radios only ever
// render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview(); loadCalendarPreview();
} }
+91
View File
@@ -0,0 +1,91 @@
// Tasks widget dialog: pick one of the viewer's own CalDAV task lists as
// this widget's source, plus the rendered preview. Not a page-load
// script -- frame_layout.js fetches this widget's dialog HTML fragment,
// injects it into the shared <dialog>, points window.FRAME_API at this
// specific widget (/api/frames/{id}/widgets/{widget_id}), then calls
// initTasksDialog().
// Rewrites #tasks-current-source in place instead of telling the user to
// reload -- the API always assigns a successful "set" to the caller
// (see api_widget_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) {
container.innerHTML = '<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>';
return;
}
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadTasksPreview() {
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
}
function initTasksDialog() {
// Choosing one of your own CalDAV task lists as this widget's source --
// owner-only (see api_widget_tasks_source), so these radios only ever
// render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
}
function closeTasksDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
@@ -127,44 +127,6 @@
</div> </div>
</section> </section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Tasks</h2>
<p class="sub">Week view only -- takes the place of one day slot
instead of adding an extra one.</p>
<div class="checkbox-row" id="tasks-enabled-row">
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg.tasks_enabled %}checked{% endif %}>
<label for="tasks_enabled">Show a task list</label>
</div>
<div id="tasks-current-source">
{% if tasks_source %}
<p class="sub" style="margin-top: 10px;">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
<strong>{{ tasks_source.label }}</strong> list.
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
</p>
{% endif %}
</div>
{% if viewer_task_calendars %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
<ul class="calendar-user-list">
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in
<a href="/settings">Settings</a> first (a plain ICS subscription
doesn't carry tasks).</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -0,0 +1,44 @@
<h2 class="dialog-title">Tasks widget</h2>
<section class="card">
<h2 class="card-title">Task list source</h2>
<p class="sub">A simple outstanding-task checklist. CalDAV only -- a
task list is a VTODO collection, which a plain ICS subscription
doesn't carry.</p>
<div id="tasks-current-source">
{% if tasks_source %}
<p class="sub" style="margin-top: 10px;">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
<strong>{{ tasks_source.label }}</strong> list.
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
</p>
{% else %}
<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>
{% endif %}
</div>
{% if viewer_task_calendars %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
<ul class="calendar-user-list">
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and tasks_source.calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in
<a href="/settings">Settings</a> first.</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="tasks-preview" alt="Tasks preview">
<button type="button" class="secondary" id="tasks-preview-refresh">Refresh now</button>
</section>
+1
View File
@@ -58,5 +58,6 @@
<script src="/static/queue.js"></script> <script src="/static/queue.js"></script>
<script src="/static/widget_dialog_calendar.js"></script> <script src="/static/widget_dialog_calendar.js"></script>
<script src="/static/widget_dialog_whiteboard.js"></script> <script src="/static/widget_dialog_whiteboard.js"></script>
<script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/frame_layout.js"></script> <script src="/static/frame_layout.js"></script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -36,10 +36,11 @@ Each module in this package exposes:
from __future__ import annotations from __future__ import annotations
from . import calendar, photos, whiteboard from . import calendar, photos, tasks, whiteboard
WIDGET_TYPES = { WIDGET_TYPES = {
"photos": photos, "photos": photos,
"calendar": calendar, "calendar": calendar,
"whiteboard": whiteboard, "whiteboard": whiteboard,
"tasks": tasks,
} }
+1 -6
View File
@@ -24,7 +24,6 @@ from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget from ..models import CalendarWidgetConfig, Frame, Widget
from ..routers.common import ( from ..routers.common import (
get_or_refresh_calendar_events_for_widget, get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget, get_or_refresh_weather_for_widget,
) )
@@ -47,16 +46,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
cfg = db.get(CalendarWidgetConfig, widget.id) cfg = db.get(CalendarWidgetConfig, widget.id)
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (cfg.view == "week" and cfg.tasks_enabled) else None
)
return _build( return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h, events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start, timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units, palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks, week_days=cfg.week_days, week_layout=cfg.week_layout,
week_start_offset=cfg.week_start_offset, week_start_offset=cfg.week_start_offset,
) )
+36
View File
@@ -0,0 +1,36 @@
"""Tasks widget: a simple outstanding-task checklist in its own region --
split out of the calendar widget's old week-view-only task list (see
models.TaskWidgetConfig) so a task list can be placed and sized on its
own, independent of any calendar's view/footprint.
No "enabled" concept and no button actions: the widget's mere presence
on the grid is the on/off switch (same as every other widget type), and
its cache refreshes on the same throttled schedule as weather -- nothing
here to advance/back/force."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from ..calendar_render import _build_tasks
from ..models import Frame, TaskWidgetConfig, Widget
from ..routers.common import get_or_refresh_tasks_for_widget
from ._shared import placeholder_image
ACTION_LABELS: dict[str, str] = {}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
cfg = db.get(TaskWidgetConfig, widget.id)
if not cfg.calendar_key or not cfg.user_id:
return placeholder_image(target_w, target_h, ["Tasks widget", "not configured yet"])
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
return _build_tasks(tasks, target_w, target_h)
ACTIONS: dict = {}
+89 -2
View File
@@ -20,6 +20,7 @@ from app.models import (
FrameCalendar, FrameCalendar,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
TaskWidgetConfig,
Widget, Widget,
WhiteboardWidgetConfig, 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 _ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
migrate.""" migrate."""
with db_module.engine.begin() as conn: 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"): "calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}")) conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) 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"] 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): 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 """A frame whose mode was "calendar" (not "photos") gets a calendar
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_ 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 the widget backfill, not as a numbered migration racing ahead of
it (see _ensure_frame_calendars_rekeyed's own docstring).""" it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn: 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"): "calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}")) conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
+20 -9
View File
@@ -13,6 +13,7 @@ from app.models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameCalendar, FrameCalendar,
TaskWidgetConfig,
User, User,
Widget, Widget,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
@@ -62,6 +63,16 @@ def _add_calendar_widget(db_session, frame: Frame) -> Widget:
return 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 --- # --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session): 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): def test_tasks_source_set_always_targets_the_caller(client, db_session):
frame = _setup_two_linked_users(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() client.cookies.clear()
login(client, "bob") 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 assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one() bob_row = db_session.query(User).filter_by(username="bob").one()
cfg = db_session.get(CalendarWidgetConfig, widget.id) cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.tasks_user_id == bob_row.id assert cfg.user_id == bob_row.id
assert cfg.tasks_calendar_key == "caldav:/some/tasks/" assert cfg.calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session): def test_tasks_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(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", client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client)) 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)) headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id) cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.tasks_user_id is None assert cfg.user_id is None
assert cfg.tasks_calendar_key is None assert cfg.calendar_key is None
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session): 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 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) frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame) photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source", 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 import pytest
from PIL import Image 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.grid import cell_to_pixels, full_panel_rect, grid_dims
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder 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 assert len(data) == EXPECTED_BYTES
def test_calendar_render_size_with_fetch_summary_and_tasks(): def test_calendar_render_size_with_fetch_summary():
tasks = [{"summary": "Buy milk", "completed": False}, {"summary": "Walk the dog", "completed": True}]
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape", data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable", 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 assert len(data) == EXPECTED_BYTES
@@ -69,6 +68,25 @@ def test_calendar_render_size_with_week_start_offset():
assert len(data) == EXPECTED_BYTES 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) --- # --- 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", f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5", data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true", "calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
"calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"}, "calendar_weather_units": "celsius"},
headers=csrf_headers(client), headers=csrf_headers(client),
) )
assert resp.status_code == 200, resp.text 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.week_layout == "vertical"
assert cfg.weather_enabled is True assert cfg.weather_enabled is True
assert cfg.weather_units == "celsius" assert cfg.weather_units == "celsius"
assert cfg.tasks_enabled is True
def test_config_save_only_partially_updates_provided_fields(client, db_session): 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 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", monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: (events or [], "")) lambda db, frame, widget: (events or [], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget", monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: weather or []) 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): 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: ([], "")) lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget", monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: calls.append(1) or []) 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) widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == [] 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): def test_advance_action_increments_browse_offset(db_session):
frame, widget = _make_widget(db_session, view="week", browse_offset=0) frame, widget = _make_widget(db_session, view="week", browse_offset=0)
widgets.calendar.ACTIONS["advance"](db_session, frame, widget) 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 == {}