Let a tasks widget merge multiple task lists, checkbox+color like calendar
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 2m8s
Build and push server image / deploy (push) Successful in 59s

Tasks widgets could only ever point at one CalDAV task list (a radio-
button picker, owner-only). Now they merge any number of included task
lists across every linked user, same checkbox-inclusion + optional
pinned-color shape a calendar widget already has for its calendars --
FrameTaskList mirrors FrameCalendar exactly, down to the same owner-
adds/anyone-mutes permission split (api_widget_task_list_select/
api_widget_task_list_color). Reused calendar_render._event_colors/
_draw_color_bar as-is for the per-task color bar -- a task dict's
owner_display_name/color_index is exactly that function's single-
source fallback shape.

Also added an opt-in "show tasks completed in the last 24 hours"
toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks
now accepts a completed_since cutoff and returns completed VTODOs
(with their completion time) instead of silently dropping them, and
_draw_tasks gives a completed task a filled checkbox + muted text
instead of the normal empty-box/due-date row.

Migration 18 splits the single-source TaskWidgetConfig columns
(added by 17, splitting tasks out of the calendar widget in the first
place) into frame_task_lists, carrying forward each widget's existing
single source as its first included list -- same shape migration 9
used carrying forward frame_calendars' old single opt-in.

Verified live in the browser (desktop + mobile): the new "Included
task lists" + "Recently completed" dialog sections, the show_completed
toggle actually persisting through a real HTTP round-trip, and no
regression in the calendar widget's own "Included calendars" dialog.
Full suite (192 tests, including new merge_tasks/config_save/migration
coverage) passes.
This commit is contained in:
Thomas Faour
2026-07-25 03:39:26 +00:00
parent b5c52004c8
commit 14c47aa2a0
16 changed files with 884 additions and 326 deletions
+15 -9
View File
@@ -32,12 +32,17 @@ a button press does.
mirrors `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 `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only task list); split into its own `CalendarWidgetConfig` (a week-view-only, single-list task list); split
widget type (migration 17) so a task list can be placed and sized into its own widget type (migration 17) so a task list can be placed
independent of any calendar's view/footprint. and sized independent of any calendar's view/footprint, then (migration
- `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame 18) given the same multi-source shape a calendar widget already has.
can now have more than one independent calendar widget, each with its - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
own set of included calendars. `frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical
shape and permission model (owner-added, anyone-linked-can-mute, see
"Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"`
calendar_key variant, since a plain ICS subscription has no VTODO
(task) collection.
- `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`], - `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`],
`widget_id`, `action`, `sort_order`) -- see "Button actions" below. `widget_id`, `action`, `sort_order`) -- see "Button actions" below.
@@ -123,9 +128,10 @@ button behavior -- see `migration.py`'s `_default_button_actions`.
## Per-widget config UI ## Per-widget config UI
Each widget has a gear-icon button on the Layout canvas that opens a Each widget has a gear-icon button on the Layout canvas that opens a
`<dialog>` with that widget's own settings (album, calendar inclusion, `<dialog>` with that widget's own settings (album, calendar/task-list
whiteboard source, etc.) -- not a per-frame tab, since a frame can now inclusion, whiteboard source, etc.) -- not a per-frame tab, since a
have several widgets of the same type with independent settings. The frame can now have several widgets of the same type with independent
settings. The
dialog HTML is injected server-rendered (`routers/frame_pages.py`'s dialog HTML is injected server-rendered (`routers/frame_pages.py`'s
`widget_dialog`, dispatching on `widget.widget_type`); its JS is a `widget_dialog`, dispatching on `widget.widget_type`); its JS is a
top-level, always-loaded file (`static/widget_dialog_*.js`) exposing top-level, always-loaded file (`static/widget_dialog_*.js`) exposing
+76 -9
View File
@@ -26,6 +26,7 @@ calendar and an ICS subscription behave identically once fetched.
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
import caldav import caldav
@@ -120,10 +121,18 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str,
return events return events
def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]: def fetch_tasks(calendar_url: str, username: str, password: str,
"""Outstanding (not-completed) VTODOs from one CalDAV task list, completed_since: datetime | None = None) -> list[dict]:
sorted by due date (tasks with no due date sort last). """Outstanding VTODOs from one CalDAV task list, plus -- when
{"summary", "due" (ISO date/datetime string, or None)}, ... completed_since is given -- ones completed at or after that cutoff
(see routers/common.py's get_or_refresh_tasks_for_widget, which
passes "now - 24h" when TaskWidgetConfig.show_completed is on;
None, the default, means completed tasks are dropped entirely, the
original behavior). {"summary", "due" (ISO date/datetime string or
None), "completed_at" (ISO datetime string, or None for an
outstanding task)}, ... . Outstanding tasks sort first (by due date,
no-due-date last), any included completed ones after (most recently
completed first).
Fetches every task including completed ones and filters/sorts Fetches every task including completed ones and filters/sorts
client-side rather than trusting get_todos()'s own client-side rather than trusting get_todos()'s own
@@ -138,7 +147,8 @@ def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]:
except Exception as e: except Exception as e:
raise CalDavError(str(e)) from e raise CalDavError(str(e)) from e
tasks: list[dict] = [] outstanding: list[dict] = []
completed: list[dict] = []
for obj in objects: for obj in objects:
try: try:
ical = icalendar.Calendar.from_ical(obj.data) ical = icalendar.Calendar.from_ical(obj.data)
@@ -147,12 +157,69 @@ def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]:
continue continue
for component in ical.walk("VTODO"): for component in ical.walk("VTODO"):
status = str(component.get("STATUS") or "NEEDS-ACTION").upper() status = str(component.get("STATUS") or "NEEDS-ACTION").upper()
summary = str(component.get("SUMMARY") or "(untitled)")
if status == "COMPLETED": if status == "COMPLETED":
completed_prop = component.get("COMPLETED")
completed_dt = completed_prop.dt if completed_prop is not None else None
if completed_since is None or completed_dt is None or completed_dt < completed_since:
continue continue
completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()})
else:
due = component.get("DUE") due = component.get("DUE")
tasks.append({ outstanding.append({
"summary": str(component.get("SUMMARY") or "(untitled)"), "summary": summary,
"due": due.dt.isoformat() if due is not None else None, "due": due.dt.isoformat() if due is not None else None,
"completed_at": None,
}) })
tasks.sort(key=lambda t: (t["due"] is None, t["due"] or "")) outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
return tasks completed.sort(key=lambda t: t["completed_at"], reverse=True)
return outstanding + completed
@dataclass
class TaskSource:
"""One task list to merge in -- CalDAV only, no ICS variant (a plain
ICS subscription has no VTODO collection to speak of).
owner_display_name tags every task pulled from this source so a
merged checklist can show whose task is whose; color_index (2-5,
into image_pipeline.DEFAULT_PALETTE_RGB) is this list's manually
pinned color, or None for calendar_render.py's auto-cycle-by-owner-
name fallback -- see models.FrameTaskList."""
owner_display_name: str
url: str
username: str
password: str
color_index: int | None = None
def merge_tasks(sources: list[TaskSource], completed_since: datetime | None = None) -> tuple[list[dict], str]:
"""Fetches each source independently -- one broken list never blanks
another's tasks. Returns (merged_tasks, fetch_summary); fetch_summary
is "" when every source succeeded, else "N of M task lists
unavailable" (same no-naming-names posture as calendar_feed.
merge_events). No cross-list duplicate collapsing (unlike
merge_events) -- a task synced to two lists at once is rare enough,
and lower-stakes than a duplicated calendar event, not to be worth
the same de-dup machinery."""
merged: list[dict] = []
failures = 0
for source in sources:
try:
tasks = fetch_tasks(source.url, source.username, source.password, completed_since=completed_since)
except CalDavError:
failures += 1
continue
for task in tasks:
merged.append({
**task,
"owner_display_name": source.owner_display_name,
"color_index": source.color_index,
})
outstanding = [t for t in merged if t["completed_at"] is None]
completed = [t for t in merged if t["completed_at"] is not None]
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
completed.sort(key=lambda t: t["completed_at"], reverse=True)
summary = f"{failures} of {len(sources)} task lists unavailable" if failures else ""
return outstanding + completed, summary
+42 -21
View File
@@ -272,7 +272,7 @@ def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
text_font: ImageFont.ImageFont, max_width: int) -> None: text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None:
"""Draws `text` left-to-right, switching between text_font (normal """Draws `text` left-to-right, switching between text_font (normal
characters) and an emoji glyph image (actual emoji runs, per characters) and an emoji glyph image (actual emoji runs, per
_split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a _split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
@@ -281,8 +281,9 @@ def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int,
across mixed fonts/images, so it works run-by-run instead (and can't across mixed fonts/images, so it works run-by-run instead (and can't
partially truncate an emoji run the way it can a text run -- one partially truncate an emoji run the way it can a text run -- one
that doesn't fit just isn't drawn). Fine for the short single-line that doesn't fit just isn't drawn). Fine for the short single-line
strings this draws (event titles), not meant as a general rich-text strings this draws (event/task titles), not meant as a general
layout engine.""" rich-text layout engine. `fill` only affects text runs -- emoji
glyphs are already their own color."""
x, y = xy x, y = xy
cursor = x cursor = x
# A little taller than text_font's own size so glyphs don't look # A little taller than text_font's own size so glyphs don't look
@@ -304,10 +305,11 @@ def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int,
else: else:
run_w = draw.textlength(run_text, font=text_font) run_w = draw.textlength(run_text, font=text_font)
if run_w <= remaining: if run_w <= remaining:
draw_text(img, (round(cursor), y), run_text, text_font) draw_text(img, (round(cursor), y), run_text, text_font, fill)
cursor += run_w cursor += run_w
else: else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font) draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining),
text_font, fill)
break break
@@ -527,14 +529,25 @@ 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) -> None: tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box palette_rgb: list | None = None) -> None:
glyph + due date (if any) + summary per outstanding task, same """A simple checklist filling `region` (x0, y0, w, h) -- a color bar
header/rule/row-cap/truncation shape as _draw_agenda_day's event (reusing _event_colors/_draw_color_bar as-is: a task dict's
list so the standalone tasks widget (see _build_tasks) reads as the top-level owner_display_name/color_index is exactly _event_colors'
same consistent design as everything else on-panel, not a single-source fallback shape, since caldav_client.merge_tasks
bolted-together look. Reuses _draw_mixed_line so a task summary with doesn't cross-list-dedup tasks into a "sources" list the way
emoji in it renders the same way an event title's does.""" merge_events dedups events) + checkbox glyph + due date (if any) +
summary per task, same header/rule/row-cap/truncation shape as
_draw_agenda_day's event list so the standalone tasks widget (see
_build_tasks) reads as the same consistent design as everything
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
so a task summary with emoji in it renders the same way an event
title's does.
Outstanding tasks get an empty checkbox; completed ones (only ever
present when TaskWidgetConfig.show_completed is on -- see
caldav_client.fetch_tasks' completed_since) get a filled one and
muted text, no due-date prefix (irrelevant once done)."""
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
@@ -549,21 +562,29 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
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)
return return
owners_seen: list[str] = []
for i, task in enumerate(tasks): for i, task in enumerate(tasks):
if i >= max_rows: if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED) draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
break break
done = task.get("completed_at") is not None
colors = _event_colors(task, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
box = body_font.size - 6 box = body_font.size - 6
box_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5 box_y = y + (row_h - box) // 2 - 5
draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2) if done:
text_x = text_x0 + box + 10 draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
due_str = _fmt_task_due(task.get("due")) else:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2)
text_x = box_x + box + 10
due_str = None if done else _fmt_task_due(task.get("due"))
prefix = f"{due_str} " if due_str else "" prefix = f"{due_str} " if due_str else ""
if prefix: if prefix:
draw_text(img, (text_x, y), prefix, body_font, MUTED) draw_text(img, (text_x, y), prefix, body_font, MUTED)
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0 prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"], _draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
body_font, text_w - box - 10 - prefix_w) body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG)
y += row_h y += row_h
@@ -873,7 +894,7 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)} _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: def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old """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, week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box.""" so this is just _draw_tasks over the whole box."""
@@ -882,7 +903,7 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int) -> Image.Image
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)] title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size) title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size) body_font = ImageFont.load_default(size=body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font) _draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb)
return img return img
@@ -892,7 +913,7 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
every other renderer honors.""" every other renderer honors."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h) img = _build_tasks(tasks, target_w, target_h, palette_rgb)
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)
@@ -904,7 +925,7 @@ def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: l
in logical (upright) orientation -- mirrors render_calendar_preview_ in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar.""" png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h) img = _build_tasks(tasks, target_w, target_h, palette_rgb)
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()
+83 -19
View File
@@ -25,6 +25,7 @@ from .models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction, FrameButtonAction,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
TaskWidgetConfig, TaskWidgetConfig,
@@ -438,6 +439,55 @@ def _migration_17(conn) -> None:
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs")) conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
def _migration_18(conn) -> None:
"""A tasks widget can now merge more than one person's CalDAV task
list, checkbox-included with an optional pinned color each -- same
multi-source shape calendar widgets already have (models.
FrameCalendar), rather than the single user_id/calendar_key pair
migration 17 gave TaskWidgetConfig when tasks first became their own
widget type. Also adds show_completed (see caldav_client.
fetch_tasks' completed_since -- off by default, so this migration
changes no widget's on-panel appearance by itself).
Each task_widget_configs row's existing single source, if any,
carries forward as that widget's first frame_task_lists row
(included) before the now-dead user_id/calendar_key columns are
dropped -- same "carry forward the old single opt-in as a row before
dropping the column" shape _migration_9 used for frame_calendars."""
conn.execute(text(
"CREATE TABLE frame_task_lists ("
"id INTEGER PRIMARY KEY, "
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_task_lists_unique ON frame_task_lists (widget_id, user_id, calendar_key)"
))
conn.execute(text(
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
"SELECT widget_id, user_id, calendar_key, 1 FROM task_widget_configs "
"WHERE calendar_key IS NOT NULL AND user_id IS NOT NULL"
))
conn.execute(text(
"CREATE TABLE task_widget_configs_new ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached TEXT, "
"show_completed INTEGER NOT NULL DEFAULT 0)"
))
conn.execute(text(
"INSERT INTO task_widget_configs_new (widget_id, checked_at, cached) "
"SELECT widget_id, checked_at, cached FROM task_widget_configs"
))
conn.execute(text("DROP TABLE task_widget_configs"))
conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -456,6 +506,7 @@ MIGRATIONS = [
(15, _migration_15), (15, _migration_15),
(16, _migration_16), (16, _migration_16),
(17, _migration_17), (17, _migration_17),
(18, _migration_18),
] ]
@@ -624,27 +675,36 @@ 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_* deliberately not carried over -- see _task_config_from_frame, # tasks_* deliberately not carried over -- see
# a sibling standalone widget now, not part of this config. # _task_config_and_list_from_frame, a sibling standalone widget
# now, not part of this config.
) )
def _task_config_from_frame(frame: Frame, widget_id: int) -> TaskWidgetConfig: def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
"""Only ever called for a frame whose legacy calendar_tasks_* columns """Only ever called for a frame whose legacy calendar_tasks_* columns
(see Frame's own docstring on those -- a dead pre-widget-system (see Frame's own docstring on those -- a dead pre-widget-system
field set, same status as calendar_photo_inlay below) still carry a field set, same status as calendar_photo_inlay below) still carry a
configured source -- i.e. a database jumping straight from before configured source -- i.e. a database jumping straight from before
the widget system existed to after tasks became its own widget type the widget system existed to after tasks became their own
in a single upgrade, skipping the intermediate period where it would multi-list widget type in a single upgrade, skipping both
have lived on CalendarWidgetConfig instead (see _migration_17's own intermediate periods where it would have lived on
extraction of *that* case).""" CalendarWidgetConfig (_migration_17's extraction) and then a
return TaskWidgetConfig( single-source TaskWidgetConfig (_migration_18's extraction) instead.
Reproduces the same shape those two migrations arrive at directly:
a bare cache-state config plus one included FrameTaskList row."""
cfg = TaskWidgetConfig(
widget_id=widget_id, 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, checked_at=frame.calendar_tasks_checked_at,
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None, cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
) )
task_list = FrameTaskList(
widget_id=widget_id,
user_id=frame.calendar_tasks_user_id,
calendar_key=frame.calendar_tasks_calendar_key,
included=True,
)
return cfg, task_list
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig: def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
@@ -679,14 +739,16 @@ 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: 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 """Only relevant for a database jumping straight from before the
widget system existed to after tasks became its own widget type in widget system existed to after tasks became their own widget type
one upgrade (see _task_config_from_frame) -- frame.calendar_tasks_* in one upgrade (see _task_config_and_list_from_frame) --
is the dead legacy field set otherwise. Auto-placed in whatever open frame.calendar_tasks_* is the dead legacy field set otherwise.
space is left after the widget(s) above it in _backfill_frame_ Requires both calendar_key and user_id (FrameTaskList.user_id is
widgets claimed theirs, same find_open_rect logic a manual "add NOT NULL) -- same guard _migration_18's own SQL extraction uses.
widget" uses; silently dropped (logged) if none fits, same as this Auto-placed in whatever open space is left after the widget(s) above
migration having nowhere else to put it either.""" it in _backfill_frame_widgets claimed theirs, same find_open_rect
if not frame.calendar_tasks_calendar_key: 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 or not frame.calendar_tasks_user_id:
return return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"] min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h) rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
@@ -701,7 +763,9 @@ def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect],
sort_order=next_sort_order, created_at=time.time()) sort_order=next_sort_order, created_at=time.time())
db.add(task_widget) db.add(task_widget)
db.flush() db.flush()
db.add(_task_config_from_frame(frame, task_widget.id)) cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
db.add(cfg)
db.add(task_list)
def _backfill_frame_widgets(db, frame: Frame) -> None: def _backfill_frame_widgets(db, frame: Frame) -> None:
+43 -14
View File
@@ -360,8 +360,8 @@ class FrameCalendar(Base):
Keyed by widget_id, not frame_id -- a frame can hold more than one Keyed by widget_id, not frame_id -- a frame can hold more than one
independent calendar widget (see Widget), each with its own included- independent calendar widget (see Widget), each with its own included-
calendars set; "included on this frame" stopped being unambiguous calendars set; "included on this frame" stopped being unambiguous
the moment that became possible (see migration.py's _migration_17, the moment that became possible (see migration.py's
which re-keyed this table).""" _ensure_frame_calendars_rekeyed, which re-keyed this table)."""
__tablename__ = "frame_calendars" __tablename__ = "frame_calendars"
@@ -386,6 +386,32 @@ class FrameCalendar(Base):
) )
class FrameTaskList(Base):
"""One CalDAV task list included on one tasks widget -- calendar_key
is "caldav:<href>" (an entry in User.calendar_caldav_calendars; no
"ics" variant, unlike FrameCalendar -- a plain ICS subscription has
no VTODO collection). Same owner-controls-their-own-data shape as
FrameCalendar in every other respect: a row is only ever created by
its own owner, but any user linked to the frame may flip included
back to False, and only the owner may flip it back to True or set
color_index. See routers/api_widgets.py's api_widget_task_list_select/
api_widget_task_list_color."""
__tablename__ = "frame_task_lists"
id: Mapped[int] = mapped_column(primary_key=True)
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_task_lists_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
class Widget(Base): class Widget(Base):
"""One placed/sized content item on a frame's panel -- the unit the """One placed/sized content item on a frame's panel -- the unit the
widget system replaces the old single Frame.mode with (see widget system replaces the old single Frame.mode with (see
@@ -485,24 +511,27 @@ class TaskWidgetConfig(Base):
old bolted-on version, the widget's mere presence on the grid is the old bolted-on version, the widget's mere presence on the grid is the
on/off switch, same as every other widget type. on/off switch, same as every other widget type.
CalDAV only (a task list is a VTODO collection, not something a Which task lists feed this widget lives in FrameTaskList, not here
plain ICS subscription meaningfully has); source is one specific -- a widget can merge more than one person's list, mirroring
linked user's own CalDAV calendar, same owner-controls-their-own- CalendarWidgetConfig/FrameCalendar exactly (this used to be a single
data permission split as FrameCalendar. user_id SET NULL on the user_id/calendar_key pair here, one list only; migration 18 carried
user's deletion clears the source rather than leaving a dangling each widget's existing single source forward as its first
reference (checked_at isn't reset by that, but the next refresh FrameTaskList row when splitting this out)."""
attempt finds no source and just returns [])."""
__tablename__ = "task_widget_configs" __tablename__ = "task_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) 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) checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...], # [{"summary", "due", "completed_at" (ISO date/datetime strings or
# already filtered to outstanding (not-completed) tasks and sorted # None), "owner_display_name", "color_index"}, ...] -- the merged
# by due date -- see caldav_client.fetch_tasks. # multi-list result, same general shape as CalendarWidgetConfig.
# cached_events. See caldav_client.merge_tasks.
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Also include tasks completed in the last 24h (drawn checked-box +
# muted, see calendar_render._draw_tasks) rather than just
# outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
class WhiteboardWidgetConfig(Base): class WhiteboardWidgetConfig(Base):
+94 -36
View File
@@ -2,7 +2,7 @@
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
source, whiteboard source, and their preview endpoints. Split out of inclusion/color, 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"
@@ -34,6 +34,7 @@ from ..models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameCalendar, FrameCalendar,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
@@ -51,6 +52,7 @@ from .common import (
immich_client_for, immich_client_for,
immich_creds, immich_creds,
list_assets, list_assets,
task_sources_for_widget,
valid_http_url, valid_http_url,
webdav_creds_for, webdav_creds_for,
) )
@@ -255,6 +257,8 @@ 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),
# tasks
tasks_show_completed: 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,6 +321,11 @@ 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
elif widget.widget_type == "tasks":
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
tcfg.show_completed = tasks_show_completed
tcfg.checked_at = 0.0 # pick up the change promptly
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"}
@@ -618,58 +627,107 @@ def api_widget_preview_calendar(
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
# --- Tasks: source/preview ------------------------------------------------ # --- Tasks: inclusion/color/preview ---------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks") @router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
def api_widget_preview_tasks( def api_widget_preview_tasks(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db) 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 """The same cached merged task list a live device render would use,
"reflects what's currently saved" convention as the other preview same "reflects what's currently saved" convention as the other
endpoints.""" preview endpoints."""
frame, widget = frame_widget frame, widget = frame_widget
_require_widget_type(widget, "tasks") _require_widget_type(widget, "tasks")
tcfg = db.get(TaskWidgetConfig, widget.id) if not task_sources_for_widget(db, widget):
if not tcfg.calendar_key or not tcfg.user_id: raise HTTPException(400, "No task lists included on this widget yet")
raise HTTPException(400, "No task list configured on this widget yet")
tasks = get_or_refresh_tasks_for_widget(db, frame, widget) tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation, png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb) palette_rgb=frame.palette_rgb)
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
class TasksSourceRequest(BaseModel): class TaskListSelectRequest(BaseModel):
calendar_key: str | None # None clears the source user_id: int
calendar_key: str
calendar_label: str = ""
included: bool
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source") @router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select")
def api_widget_tasks_source( def api_widget_task_list_select(
body: TasksSourceRequest, request: Request, body: TaskListSelectRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Points this tasks widget at one of the calling user's own CalDAV """Include/exclude one CalDAV task list (calendar_key "caldav:<href>",
calendars -- same owner-controls-their-own-data permission split as see FrameTaskList) on this tasks widget -- same one-sided permission
calendar-select's included=True, since this is volunteering personal split as api_widget_calendar_select: turning a list ON requires being
calendar data, not a display setting a controller should get to pick its owner (nobody can add someone else's task list to a shared frame
on someone else's behalf. None clears the source; clearing (unlike for them), turning one OFF only requires being linked to the frame at
setting) isn't ownership-gated -- like muting a shared calendar, all, so anyone sharing the display can mute a list they'd rather not
anyone linked to the frame can turn off a task list they'd rather see there even if they don't own it. Deliberately not
not see, but only its owner can point the widget at one of their require_widget_control for the same reason."""
calendars to begin with."""
frame, widget = frame_widget frame, widget = frame_widget
_require_widget_type(widget, "tasks") _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): if body.included and body.user_id != user.id:
if body.calendar_key is None: raise HTTPException(403, "Only a task list's owner can add it to a frame")
cfg.user_id = None row = db.execute(
cfg.calendar_key = None select(FrameTaskList).where(
cfg.cached = None FrameTaskList.widget_id == widget.id,
else: FrameTaskList.user_id == body.user_id,
cfg.user_id = user.id FrameTaskList.calendar_key == body.calendar_key,
cfg.calendar_key = body.calendar_key )
cfg.checked_at = 0.0 # pick up the change promptly ).scalar_one_or_none()
return {"status": "saved", "calendar_key": body.calendar_key} if row is None:
if not body.included:
raise HTTPException(404, "Not currently included on this widget")
row = FrameTaskList(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
db.add(row)
row.included = body.included
if body.calendar_label:
row.calendar_label = body.calendar_label
# Force this widget's merged cache to pick up the change promptly
# rather than waiting out the throttle.
db.get(TaskWidgetConfig, widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.included}
class TaskListColorRequest(BaseModel):
calendar_key: str
color_index: int | None # None clears the pin, reverting to auto-cycle
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-color")
def api_widget_task_list_color(
body: TaskListColorRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Pins a specific panel color to one of your own included task
lists (models.FrameTaskList.color_index) -- always owner-only, same
as api_widget_calendar_color. None clears the pin, reverting
calendar_render.py to its auto-cycle-by-owner-name behavior for this
list."""
frame, widget = frame_widget
_require_widget_type(widget, "tasks")
user = require_user_api(request, db)
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
row = db.execute(
select(FrameTaskList).where(
FrameTaskList.widget_id == widget.id,
FrameTaskList.user_id == user.id,
FrameTaskList.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
raise HTTPException(404, "Not included on this widget")
row.color_index = body.color_index
db.get(TaskWidgetConfig, widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "color_index": row.color_index}
class WeatherCityAddRequest(BaseModel): class WeatherCityAddRequest(BaseModel):
@@ -736,9 +794,9 @@ def api_widget_whiteboard_source(
"""Points this widget at one of the calling user's own WebDAV (or """Points this widget at one of the calling user's own WebDAV (or
reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials -- reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
same owner-controls-their-own-data permission split as same owner-controls-their-own-data permission split as
api_widget_tasks_source: only the account owner can set the widget to api_widget_task_list_select: only the account owner can set the
use it, but anyone linked to the frame can clear it, same as muting a widget to use it, but anyone linked to the frame can clear it, same
shared calendar.""" as muting a shared calendar."""
frame, widget = frame_widget frame, widget = frame_widget
_require_widget_type(widget, "whiteboard") _require_widget_type(widget, "whiteboard")
user = require_user_api(request, db) user = require_user_api(request, db)
+44 -20
View File
@@ -7,7 +7,7 @@ import logging
import os import os
import statistics import statistics
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -26,6 +26,7 @@ from ..models import (
Frame, Frame,
FrameButtonAction, FrameButtonAction,
FrameCalendar, FrameCalendar,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
User, User,
@@ -565,31 +566,54 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
def task_sources_for_widget(db: Session, widget: Widget) -> list[caldav_client.TaskSource]:
"""Every task list included on this tasks widget (FrameTaskList.
included) -- the exact set caldav_client.merge_tasks needs. CalDAV
only (calendar_key is always "caldav:<href>" -- no "ics" variant, a
plain ICS subscription has no VTODO collection), resolved against
the owning user's CalDAV account credentials."""
rows = db.execute(
select(FrameTaskList, User)
.join(User, User.id == FrameTaskList.user_id)
.where(FrameTaskList.widget_id == widget.id, FrameTaskList.included == True) # noqa: E712
).all()
sources = []
for ftl, u in rows:
if not ftl.calendar_key.startswith("caldav:") or not u.calendar_caldav_username:
continue
href = ftl.calendar_key[len("caldav:"):]
sources.append(caldav_client.TaskSource(
u.display_name or u.username, href, u.calendar_caldav_username, u.calendar_caldav_password,
color_index=ftl.color_index,
))
return sources
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 multi-list merge-fetch cache (calendar_feed.
cadence as event merging), reading/writing TaskWidgetConfig (see CHECK_INTERVAL_S, same cadence as event merging), reading/writing
app/widgets/tasks.py). [] if no source is set, or the source user's TaskWidgetConfig (see app/widgets/tasks.py). [] if no list is
CalDAV credentials/calendar_key have gone missing (e.g. they included yet. Same posture as get_or_refresh_calendar_events_for_
unlinked their account). A refetch failure keeps the last-known list widget (which this otherwise mirrors closely), not weather's own
rather than going blank for one bad cycle, same reasoning as per-city stale-cache fallback: a broken list just contributes
get_or_refresh_weather_for_widget.""" nothing to this cycle's merge (logged in fetch_summary) rather than
silently keeping its last-known tasks around."""
cfg = db.get(TaskWidgetConfig, widget.id) cfg = db.get(TaskWidgetConfig, widget.id)
if not cfg.calendar_key or not cfg.user_id:
return []
now = time.time() now = time.time()
if cfg.cached is not None and now - cfg.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.cached return cfg.cached
user = db.get(User, cfg.user_id) sources = task_sources_for_widget(db, widget)
key = cfg.calendar_key if not sources:
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"): return []
return cfg.cached or [] completed_since = datetime.now(timezone.utc) - timedelta(hours=TASKS_COMPLETED_WINDOW_HOURS) \
href = key[len("caldav:"):] if cfg.show_completed else None
try: tasks, summary = caldav_client.merge_tasks(sources, completed_since=completed_since)
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password) if summary:
except caldav_client.CalDavError as e: logger.warning("Could not refresh tasks for widget %d: %s", widget.id, summary)
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
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.cached = tasks locked_cfg.cached = tasks
+41 -20
View File
@@ -30,6 +30,7 @@ from ..models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameCalendar, FrameCalendar,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
User, User,
@@ -137,22 +138,41 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe
return result return result
def _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None: def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Whose CalDAV calendar this tasks widget currently pulls from, and """Per-linked-user task-list list for the tasks dialog's "Included
its label -- for showing "using <name>'s Chores list" to everyone task lists" section -- same shape as _calendar_users_for_widget,
linked, not just whoever set it. None if no source is configured.""" restricted to CalDAV calendars only (no "ics" option: a plain ICS
if not task_cfg.user_id or not task_cfg.calendar_key: subscription has no VTODO collection to speak of, see
return None caldav_client.fetch_tasks)."""
user = db.get(User, task_cfg.user_id) users = db.execute(
if user is None: select(User).join(UserFrame, UserFrame.user_id == User.id)
return None .where(UserFrame.frame_id == frame_id).order_by(User.username)
label = task_cfg.calendar_key ).scalars().all()
for c in (user.calendar_caldav_calendars or []): included_by_user: dict[int, list[FrameTaskList]] = {}
if f"caldav:{c['href']}" == task_cfg.calendar_key: for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all():
label = c.get("display_name") or label included_by_user.setdefault(ftl.user_id, []).append(ftl)
break
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label, result = []
"calendar_key": task_cfg.calendar_key} for u in users:
is_self = u.id == viewer_id
available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")]
if is_self:
own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])}
task_lists = [
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in available
]
else:
task_lists = [
{"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True}
for ftl in included_by_user.get(u.id, []) if ftl.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "task_lists": task_lists,
})
return result
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None: def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
@@ -208,11 +228,12 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
if widget.widget_type == "tasks": if widget.widget_type == "tasks":
task_cfg = db.get(TaskWidgetConfig, widget.id) 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", { return templates.TemplateResponse("_widget_dialog_tasks.html", {
"request": request, "frame": frame, "widget": widget, "user": user, "request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"viewer_task_calendars": viewer_task_calendars, "task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"tasks_source": _tasks_source_info(db, task_cfg), "task_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
}) })
if widget.widget_type == "whiteboard": if widget.widget_type == "whiteboard":
+62 -59
View File
@@ -1,86 +1,89 @@
// Tasks widget dialog: pick one of the viewer's own CalDAV task lists as // Tasks widget dialog: per-user included-task-list checkboxes + color
// this widget's source, plus the rendered preview. Not a page-load // pins (same shape as the calendar widget's "Included calendars"), the
// recently-completed toggle, and the rendered preview. Not a page-load
// script -- frame_layout.js fetches this widget's dialog HTML fragment, // script -- frame_layout.js fetches this widget's dialog HTML fragment,
// injects it into the shared <dialog>, points window.FRAME_API at this // injects it into the shared <dialog>, points window.FRAME_API at this
// specific widget (/api/frames/{id}/widgets/{widget_id}), then calls // specific widget (/api/frames/{id}/widgets/{widget_id}), then calls
// initTasksDialog(). // 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() { function loadTasksPreview() {
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`; document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
} }
function initTasksDialog() { function initTasksDialog() {
// Choosing one of your own CalDAV task lists as this widget's source -- // Each task list's own include/mute toggle -- auto-saves on change,
// owner-only (see api_widget_tasks_source), so these radios only ever // not batched into the form below, since it's a data-sharing choice
// render for the viewer's own calendars anyway. // (see api_widget_task_list_select), not a widget-wide setting. Works
document.querySelectorAll('.tasks-source-choice').forEach((el) => { // the same element for your own lists (full add/remove) and other
// people's (mute only) -- the server enforces which direction is
// allowed and this just reverts the checkbox with an error message if
// rejected.
document.querySelectorAll('.task-list-toggle').forEach((el) => {
el.addEventListener('change', async () => { el.addEventListener('change', async () => {
try { try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, { const resp = await fetch(`${window.FRAME_API}/task-list-select`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }), body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
}); });
if (!resp.ok) throw new Error(await apiError(resp)); if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.'); showStatus(true, el.checked ? 'Task list included on this widget.' : 'Task list removed from this widget.');
const labelEl = el.closest('li').querySelector('label'); loadTasksPreview();
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : ''); } catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
// Per-task-list color pin -- owner-only (the server enforces it;
// these buttons only ever render for the viewer's own lists anyway).
document.querySelectorAll('.task-list-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/task-list-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadTasksPreview(); loadTasksPreview();
} catch (e) { } catch (e) {
showStatus(false, e.message); showStatus(false, e.message);
} }
}); });
}); });
});
const tasksSourceClear = document.getElementById('tasks-source-clear'); document.getElementById('tasks-config-form').addEventListener('submit', async (e) => {
if (tasksSourceClear) { e.preventDefault();
tasksSourceClear.addEventListener('click', clearTasksSource); const body = new URLSearchParams({
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
} }
});
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview(); loadTasksPreview();
+47 -28
View File
@@ -1,39 +1,58 @@
<h2 class="dialog-title">Tasks widget</h2> <h2 class="dialog-title">Tasks widget</h2>
<section class="card"> <section class="card">
<h2 class="card-title">Task list source</h2> <h2 class="card-title">Included task lists</h2>
<p class="sub">A simple outstanding-task checklist. CalDAV only -- a <p class="sub">Each linked person adds their own CalDAV task lists (set
task list is a VTODO collection, which a plain ICS subscription up in <a href="/settings">Settings</a> -- a plain ICS subscription
doesn't carry.</p> has no task list) -- being linked here doesn't include anything
automatically. Anyone linked to this frame can mute a list they'd
<div id="tasks-current-source"> rather not see here, even one they don't own; only its owner can add
{% if tasks_source %} it back.</p>
<p class="sub" style="margin-top: 10px;"> <ul class="calendar-user-list">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s {% for u in task_users %}
<strong>{{ tasks_source.label }}</strong> list. <li>
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button> <p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
</p> {% if u.task_lists %}
{% else %} {% set current_palette = frame.palette_rgb or default_palette_rgb %}
<p class="sub" style="margin-top: 10px;">No task list configured yet.</p> {% set current_hex = palette_to_hex(current_palette) %}
{% for c in u.task_lists %}
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
<input type="checkbox" class="task-list-toggle"
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
{% if c.included %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
{% if u.is_self %}
<span class="task-list-color-picker" data-key="{{ c.key }}">
{% for idx in range(2, 6) %}
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
data-index="{{ idx }}" title="{{ task_color_labels[idx] }}"
style="background-color: {{ current_hex[idx] }};"></button>
{% endfor %}
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
data-index="" title="Auto (assigned automatically)">Auto</button>
</span>
{% endif %} {% endif %}
</div> </div>
{% endfor %}
{% if viewer_task_calendars %} {% elif u.is_self %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p> <p class="sub" style="margin-top: 6px;">No CalDAV task lists set up yet -- add a CalDAV account in <a href="/settings">Settings</a>.</p>
<ul class="calendar-user-list"> {% else %}
{% for c in viewer_task_calendars %} <p class="sub" style="margin-top: 6px;">No task lists included.</p>
<li class="checkbox-row" style="margin-top: 6px;"> {% endif %}
<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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} </section>
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in <section class="card" style="margin-top: 20px;">
<a href="/settings">Settings</a> first.</p> <h2 class="card-title">Recently completed</h2>
{% endif %} <form id="tasks-config-form">
<div class="checkbox-row">
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
</div>
<button type="submit">Save</button>
</form>
</section> </section>
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
+16 -11
View File
@@ -1,12 +1,21 @@
"""Tasks widget: a simple outstanding-task checklist in its own region -- """Tasks widget: a simple outstanding-task checklist merged from one or
split out of the calendar widget's old week-view-only task list (see more of its linked users' CalDAV task lists, in its own region -- split
models.TaskWidgetConfig) so a task list can be placed and sized on its out of the calendar widget's old week-view-only, single-list task list
own, independent of any calendar's view/footprint. (see models.TaskWidgetConfig) so a task list can be placed and sized on
its own, independent of any calendar's view/footprint, and can merge
more than one person's list the same way a calendar widget merges more
than one person's calendar (see models.FrameTaskList).
No "enabled" concept and no button actions: the widget's mere presence 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 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 its cache refreshes on the same throttled schedule as weather -- nothing
here to advance/back/force.""" here to advance/back/force. No placeholder for "nothing included yet"
either -- same posture as app/widgets/calendar.py, which this otherwise
mirrors closely: render() always draws through get_or_refresh_tasks_
for_widget's result even when it's [], showing "Nothing outstanding"
rather than a distinct not-configured state (the dialog's preview
endpoint is what actually 400s for that case, same asymmetry calendar
already has)."""
from __future__ import annotations from __future__ import annotations
@@ -14,9 +23,8 @@ from PIL import Image
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..calendar_render import _build_tasks from ..calendar_render import _build_tasks
from ..models import Frame, TaskWidgetConfig, Widget from ..models import Frame, Widget
from ..routers.common import get_or_refresh_tasks_for_widget from ..routers.common import get_or_refresh_tasks_for_widget
from ._shared import placeholder_image
ACTION_LABELS: dict[str, str] = {} ACTION_LABELS: dict[str, str] = {}
@@ -26,11 +34,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
"""is_normal_wake is unused here -- see app/widgets/photos.py's """is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call identical note; every widget type's render() shares one call
signature regardless of which ones actually care.""" 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) tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
return _build_tasks(tasks, target_w, target_h) return _build_tasks(tasks, target_w, target_h, frame.palette_rgb)
ACTIONS: dict = {} ACTIONS: dict = {}
+95
View File
@@ -0,0 +1,95 @@
"""caldav_client.merge_tasks -- pure-function coverage (sort, per-source
color/owner tagging, partial-failure handling), monkeypatching
fetch_tasks itself rather than the caldav package's DAVClient/Calendar
-- this project has no CalDAV test server fixture (fetch_calendar_events'
own caldav branch is likewise only exercised this way, never against a
real server -- see test_calendar_feed.py, which only covers the ICS
path). fetch_tasks' own VTODO-parsing internals are trusted to the
icalendar library, same posture calendar_feed.py already takes for
VEVENT parsing."""
from __future__ import annotations
from datetime import datetime, timezone
from app.caldav_client import CalDavError, TaskSource, merge_tasks
def test_single_source_tasks_pass_through_tagged(monkeypatch):
monkeypatch.setattr(
"app.caldav_client.fetch_tasks",
lambda url, username, password, completed_since=None: [
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None},
],
)
sources = [TaskSource("Alice", "https://example.com/tasks", "alice", "pw", color_index=3)]
tasks, summary = merge_tasks(sources)
assert summary == ""
assert tasks == [
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None,
"owner_display_name": "Alice", "color_index": 3},
]
def test_outstanding_tasks_sort_by_due_date_none_last(monkeypatch):
def fake_fetch(url, username, password, completed_since=None):
by_url = {
"a": [{"summary": "No due date", "due": None, "completed_at": None}],
"b": [{"summary": "Due soonest", "due": "2026-08-01", "completed_at": None}],
}
return by_url[url]
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
tasks, _ = merge_tasks(sources)
assert [t["summary"] for t in tasks] == ["Due soonest", "No due date"]
def test_completed_tasks_sort_after_outstanding_most_recent_first(monkeypatch):
def fake_fetch(url, username, password, completed_since=None):
return [
{"summary": "Outstanding", "due": "2026-08-05", "completed_at": None},
{"summary": "Done yesterday", "due": None, "completed_at": "2026-08-01T09:00:00+00:00"},
{"summary": "Done today", "due": None, "completed_at": "2026-08-02T09:00:00+00:00"},
]
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
tasks, _ = merge_tasks([TaskSource("Alice", "a", "alice", "pw")])
assert [t["summary"] for t in tasks] == ["Outstanding", "Done today", "Done yesterday"]
def test_one_broken_source_does_not_blank_others(monkeypatch):
def fake_fetch(url, username, password, completed_since=None):
if url == "broken":
raise CalDavError("nope")
return [{"summary": "Buy milk", "due": None, "completed_at": None}]
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
sources = [TaskSource("Alice", "ok", "alice", "pw"), TaskSource("Bob", "broken", "bob", "pw")]
tasks, summary = merge_tasks(sources)
assert len(tasks) == 1
assert summary == "1 of 2 task lists unavailable"
def test_all_sources_unreachable(monkeypatch):
monkeypatch.setattr(
"app.caldav_client.fetch_tasks",
lambda url, username, password, completed_since=None: (_ for _ in ()).throw(CalDavError("nope")),
)
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
tasks, summary = merge_tasks(sources)
assert tasks == []
assert summary == "2 of 2 task lists unavailable"
def test_completed_since_is_forwarded_to_fetch_tasks(monkeypatch):
seen = []
def fake_fetch(url, username, password, completed_since=None):
seen.append(completed_since)
return []
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
cutoff = datetime(2026, 8, 1, tzinfo=timezone.utc)
merge_tasks([TaskSource("Alice", "a", "alice", "pw")], completed_since=cutoff)
assert seen == [cutoff]
+28 -12
View File
@@ -18,6 +18,7 @@ from app.models import (
Frame, Frame,
FrameButtonAction, FrameButtonAction,
FrameCalendar, FrameCalendar,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
TaskWidgetConfig, TaskWidgetConfig,
@@ -180,7 +181,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", "task_widget_configs", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"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"))
@@ -234,16 +235,22 @@ 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): def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17's actual data-extraction SQL (the real """Exercises _migration_17 and _migration_18's actual data-extraction
"existing widget-system database upgrading past this migration" SQL back to back (the real "existing widget-system database
scenario): a calendar_widget_configs row in its pre-17 shape (tasks_* upgrading past both migrations" scenario, since both apply in the
columns still present, still holding a configured task source) should same run_migrations() call here): a calendar_widget_configs row in
come out the other side as a sibling `tasks` widget carrying that its pre-17 shape (tasks_* columns still present, still holding a
source, with calendar_widget_configs no longer having tasks_* configured task source) should come out the other side as a sibling
columns at all.""" `tasks` widget with that source carried forward as its first
FrameTaskList row (migration 17's extraction, then migration 18's
further extraction of the single-source TaskWidgetConfig it produces
into FrameTaskList), with calendar_widget_configs no longer having
tasks_* columns and TaskWidgetConfig no longer having user_id/
calendar_key columns either."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
conn.execute(text("DROP TABLE task_widget_configs")) conn.execute(text("DROP TABLE task_widget_configs"))
conn.execute(text("DROP TABLE frame_task_lists"))
conn.execute(text("DROP TABLE calendar_widget_configs")) conn.execute(text("DROP TABLE calendar_widget_configs"))
conn.execute(text( conn.execute(text(
"CREATE TABLE calendar_widget_configs (" "CREATE TABLE calendar_widget_configs ("
@@ -301,6 +308,9 @@ def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
columns = {c["name"] for c in inspect(db_module.engine).get_columns("calendar_widget_configs")} 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) assert not any(c.startswith("tasks_") for c in columns)
task_cfg_columns = {c["name"] for c in inspect(db_module.engine).get_columns("task_widget_configs")}
assert "user_id" not in task_cfg_columns and "calendar_key" not in task_cfg_columns
assert "show_completed" in task_cfg_columns
task_widgets = db_session.scalars( task_widgets = db_session.scalars(
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks") select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
@@ -309,10 +319,16 @@ def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
task_widget = task_widgets[0] task_widget = task_widgets[0]
cfg = db_session.get(TaskWidgetConfig, task_widget.id) 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.checked_at == 123.0
assert cfg.cached == [{"summary": "Buy milk"}] assert cfg.cached == [{"summary": "Buy milk"}]
assert cfg.show_completed is False
task_list = db_session.scalars(
select(FrameTaskList).where(FrameTaskList.widget_id == task_widget.id)
).one()
assert task_list.user_id == user_id
assert task_list.calendar_key == "caldav:/some/tasks/"
assert task_list.included is True
# Auto-placed without overlapping the calendar widget it was split from. # Auto-placed without overlapping the calendar widget it was split from.
cal = db_session.get(Widget, calendar_widget_id) cal = db_session.get(Widget, calendar_widget_id)
@@ -330,7 +346,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", "task_widget_configs", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"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"))
+123 -35
View File
@@ -1,8 +1,9 @@
"""The "owner controls adding their own data; anyone linked can mute it" """The "owner controls adding their own data; anyone linked can mute it"
permission pattern, repeated across calendar-select, tasks-source, and permission pattern, repeated across calendar-select, task-list-select,
whiteboard-source -- exercised at the HTTP layer (not just unit-level) and whiteboard-source (plus the owner-only, no-mute-split calendar-color/
task-list-color) -- exercised at the HTTP layer (not just unit-level)
since the whole point is verifying the *endpoint's* authorization check, since the whole point is verifying the *endpoint's* authorization check,
not just a helper function's logic. All three now live under not just a helper function's logic. All of these live under
/api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py).""" /api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py)."""
from __future__ import annotations from __future__ import annotations
@@ -13,6 +14,7 @@ from app.models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameCalendar, FrameCalendar,
FrameTaskList,
TaskWidgetConfig, TaskWidgetConfig,
User, User,
Widget, Widget,
@@ -165,53 +167,139 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
assert resp.status_code == 400 assert resp.status_code == 400
# --- tasks-source --- # --- task-list-select ---
def test_tasks_source_set_always_targets_the_caller(client, db_session): def test_task_list_select_bob_cannot_add_alices_list(client, db_session):
frame = _setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame) widget = _add_tasks_widget(db_session, frame)
client.cookies.clear() alice_id = db_session.query(User).filter_by(username="alice").one().id
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id == bob_row.id
assert cfg.calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
client.cookies.clear() client.cookies.clear()
login(client, "bob") login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None}, resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
headers=csrf_headers(client)) "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 403
def test_task_list_select_owner_can_add_their_own(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
cfg = db_session.get(TaskWidgetConfig, widget.id) row = db_session.query(FrameTaskList).filter_by(
assert cfg.user_id is None widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
assert cfg.calendar_key is None ).one()
assert row.included is True
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session): def test_task_list_select_bob_can_mute_alices_list(client, db_session):
_setup_two_linked_users(client, db_session) """Muting is a display-preference veto anyone linked gets, unlike
resp = client.post("/api/frames/1/widgets/999999/tasks-source", adding -- the one-sided half of this endpoint's permission split."""
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameTaskList).filter_by(
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
).one()
assert row.included is False
def test_task_list_select_cannot_mute_a_list_that_was_never_added(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 404 assert resp.status_code == 404
def test_tasks_source_400s_when_widget_is_not_tasks(client, db_session): def test_task_list_select_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/widgets/999999/task-list-select", json={
"user_id": 1, "calendar_key": "caldav:/some/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_task_list_select_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", alice_id = db_session.query(User).filter_by(username="alice").one().id
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 400
# --- task-list-color ---
def test_task_list_color_owner_can_pin_it(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
"calendar_key": "caldav:/alice/tasks/", "color_index": 3,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameTaskList).filter_by(
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
).one()
assert row.color_index == 3
def test_task_list_color_bob_cannot_recolor_alices_list(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
"calendar_key": "caldav:/alice/tasks/", "color_index": 3,
}, headers=csrf_headers(client))
assert resp.status_code == 404 # bob has no row for alice's list to recolor
def test_task_list_color_rejects_out_of_range_index(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
"calendar_key": "caldav:/alice/tasks/", "color_index": 99,
}, headers=csrf_headers(client))
assert resp.status_code == 400 assert resp.status_code == 400
@@ -9,7 +9,7 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data
from __future__ import annotations from __future__ import annotations
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, Widget from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget
from .conftest import csrf_headers from .conftest import csrf_headers
@@ -32,6 +32,18 @@ def _add_calendar_widget(db_session) -> Widget:
return widget return widget
def _add_tasks_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, 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
def _mock_immich(monkeypatch): def _mock_immich(monkeypatch):
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object()) monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS) monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
@@ -80,6 +92,21 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
assert cfg.weather_units == "celsius" assert cfg.weather_units == "celsius"
def test_config_save_updates_a_tasks_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"tasks_show_completed": "true"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.show_completed 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):
"""Fields not present in the POST are left untouched -- the whole """Fields not present in the POST are left untouched -- the whole
point of the partial-update convention (each dialog's own form only point of the partial-update convention (each dialog's own form only
+39 -24
View File
@@ -1,8 +1,11 @@
"""app.widgets.tasks -- unit-level, no HTTP: constructs Widget/ """app.widgets.tasks -- unit-level, no HTTP: constructs Widget/
TaskWidgetConfig rows directly and monkeypatches the underlying fetch TaskWidgetConfig rows directly and monkeypatches the underlying fetch
call (get_or_refresh_tasks_for_widget, throttle/CalDAV-fetch logic call (get_or_refresh_tasks_for_widget, throttle/multi-list-merge/CalDAV
covered separately). Split out of the old calendar widget's week-view- -fetch logic covered separately -- see test_widget_config_and_queue_
only task list -- see models.TaskWidgetConfig.""" endpoints.py and test_permission_boundaries.py for the FrameTaskList
inclusion/color HTTP-layer coverage). Split out of the old calendar
widget's week-view-only, single-list task list -- see
models.TaskWidgetConfig/FrameTaskList."""
from __future__ import annotations from __future__ import annotations
@@ -11,8 +14,6 @@ import time
from app import widgets from app import widgets
from app.models import Frame, TaskWidgetConfig, Widget from app.models import Frame, TaskWidgetConfig, Widget
from .conftest import make_user
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]: def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
@@ -25,30 +26,44 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
return frame, widget return frame, widget
def _make_configured_widget(db_session) -> tuple[Frame, Widget]: def test_render_produces_a_correctly_sized_image_with_no_lists_included(db_session, monkeypatch):
user = make_user(db_session, "task-owner") """No placeholder for "nothing included yet" -- same posture as
return _make_widget(db_session, user_id=user.id, calendar_key="caldav:/some/tasks/") app/widgets/calendar.py (see tasks.py's own module docstring):
get_or_refresh_tasks_for_widget already returns [] with nothing
included, and render() draws straight through that."""
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session): frame, widget = _make_widget(db_session)
frame, widget = _make_widget(db_session) # no user_id/calendar_key set 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_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) img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200) assert img.size == (300, 200)
assert img.mode == "RGB" assert img.mode == "RGB"
def test_render_configured_but_empty_task_list_still_renders(db_session, monkeypatch): def test_render_produces_a_correctly_sized_image_with_tasks(db_session, monkeypatch):
frame, widget = _make_configured_widget(db_session) frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [
{"summary": "Buy milk", "due": None, "completed_at": None,
"owner_display_name": "Alice", "color_index": None},
])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_with_completed_tasks(db_session, monkeypatch):
"""show_completed's merged result includes completed tasks too (see
caldav_client.fetch_tasks' completed_since) -- render() must handle
that shape (a non-None completed_at) without error."""
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [
{"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None,
"owner_display_name": "Alice", "color_index": 2},
{"summary": "Buy milk", "due": None, "completed_at": "2026-08-01T10:00:00+00:00",
"owner_display_name": "Alice", "color_index": 2},
])
img = widgets.tasks.render(db_session, frame, widget, 300, 200) img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200) assert img.size == (300, 200)
@@ -58,7 +73,7 @@ def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
"""grid.MIN_FOOTPRINT["tasks"] is (2, 2) cells -- on an 8x5 grid """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 against a full 800x480 panel that's a 200x192 box, the smallest a
tasks widget can actually be placed at.""" tasks widget can actually be placed at."""
frame, widget = _make_configured_widget(db_session) frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
img = widgets.tasks.render(db_session, frame, widget, 200, 192) img = widgets.tasks.render(db_session, frame, widget, 200, 192)