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
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
can now have more than one independent calendar widget, each with its
own set of included calendars.
`CalendarWidgetConfig` (a week-view-only, single-list 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, then (migration
18) given the same multi-source shape a calendar widget already has.
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`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"`],
`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
Each widget has a gear-icon button on the Layout canvas that opens a
`<dialog>` with that widget's own settings (album, calendar inclusion,
whiteboard source, etc.) -- not a per-frame tab, since a frame can now
have several widgets of the same type with independent settings. The
`<dialog>` with that widget's own settings (album, calendar/task-list
inclusion, whiteboard source, etc.) -- not a per-frame tab, since a
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
`widget_dialog`, dispatching on `widget.widget_type`); its JS is a
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
import logging
from dataclasses import dataclass
from datetime import date, datetime
import caldav
@@ -120,10 +121,18 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str,
return events
def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]:
"""Outstanding (not-completed) VTODOs from one CalDAV task list,
sorted by due date (tasks with no due date sort last).
{"summary", "due" (ISO date/datetime string, or None)}, ...
def fetch_tasks(calendar_url: str, username: str, password: str,
completed_since: datetime | None = None) -> list[dict]:
"""Outstanding VTODOs from one CalDAV task list, plus -- when
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
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:
raise CalDavError(str(e)) from e
tasks: list[dict] = []
outstanding: list[dict] = []
completed: list[dict] = []
for obj in objects:
try:
ical = icalendar.Calendar.from_ical(obj.data)
@@ -147,12 +157,69 @@ def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]:
continue
for component in ical.walk("VTODO"):
status = str(component.get("STATUS") or "NEEDS-ACTION").upper()
summary = str(component.get("SUMMARY") or "(untitled)")
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
completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()})
else:
due = component.get("DUE")
tasks.append({
"summary": str(component.get("SUMMARY") or "(untitled)"),
outstanding.append({
"summary": summary,
"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 ""))
return tasks
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
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,
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
characters) and an emoji glyph image (actual emoji runs, per
_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
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
strings this draws (event titles), not meant as a general rich-text
layout engine."""
strings this draws (event/task titles), not meant as a general
rich-text layout engine. `fill` only affects text runs -- emoji
glyphs are already their own color."""
x, y = xy
cursor = x
# 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:
run_w = draw.textlength(run_text, font=text_font)
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
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
@@ -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],
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont) -> None:
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
glyph + due date (if any) + summary per outstanding 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."""
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
palette_rgb: list | None = None) -> None:
"""A simple checklist filling `region` (x0, y0, w, h) -- a color bar
(reusing _event_colors/_draw_color_bar as-is: a task dict's
top-level owner_display_name/color_index is exactly _event_colors'
single-source fallback shape, since caldav_client.merge_tasks
doesn't cross-list-dedup tasks into a "sources" list the way
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
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - MARGIN * 2
@@ -549,21 +562,29 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
return
owners_seen: list[str] = []
for i, task in enumerate(tasks):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
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_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5
draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2)
text_x = text_x0 + box + 10
due_str = _fmt_task_due(task.get("due"))
if done:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
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 ""
if prefix:
draw_text(img, (text_x, y), prefix, body_font, MUTED)
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"],
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
@@ -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)}
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
week-view slot, there's no day columns/header to share space with,
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_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)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb)
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
every other renderer honors."""
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)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
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_
png's relationship to render_calendar."""
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)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
+83 -19
View File
@@ -25,6 +25,7 @@ from .models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
@@ -438,6 +439,55 @@ def _migration_17(conn) -> None:
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 = [
(1, _migration_1),
(2, _migration_2),
@@ -456,6 +506,7 @@ MIGRATIONS = [
(15, _migration_15),
(16, _migration_16),
(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_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset,
# tasks_* deliberately not carried over -- see _task_config_from_frame,
# a sibling standalone widget now, not part of this config.
# tasks_* deliberately not carried over -- see
# _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
(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(
the widget system existed to after tasks became their own
multi-list widget type in a single upgrade, skipping both
intermediate periods where it would have lived on
CalendarWidgetConfig (_migration_17's extraction) and then a
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,
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,
)
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:
@@ -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:
"""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:
widget system existed to after tasks became their own widget type
in one upgrade (see _task_config_and_list_from_frame) --
frame.calendar_tasks_* is the dead legacy field set otherwise.
Requires both calendar_key and user_id (FrameTaskList.user_id is
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
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 or not frame.calendar_tasks_user_id:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
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())
db.add(task_widget)
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:
+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
independent calendar widget (see Widget), each with its own included-
calendars set; "included on this frame" stopped being unambiguous
the moment that became possible (see migration.py's _migration_17,
which re-keyed this table)."""
the moment that became possible (see migration.py's
_ensure_frame_calendars_rekeyed, which re-keyed this table)."""
__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):
"""One placed/sized content item on a frame's panel -- the unit the
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
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 [])."""
Which task lists feed this widget lives in FrameTaskList, not here
-- a widget can merge more than one person's list, mirroring
CalendarWidgetConfig/FrameCalendar exactly (this used to be a single
user_id/calendar_key pair here, one list only; migration 18 carried
each widget's existing single source forward as its first
FrameTaskList row when splitting this out)."""
__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.
# [{"summary", "due", "completed_at" (ISO date/datetime strings or
# None), "owner_display_name", "color_index"}, ...] -- the merged
# 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)
# 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):
+94 -36
View File
@@ -2,7 +2,7 @@
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
plus every setting/action that used to assume a frame had at most one
widget of a given type -- photo queue, calendar inclusion/color, tasks
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
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"
@@ -34,6 +34,7 @@ from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
TaskWidgetConfig,
WhiteboardWidgetConfig,
@@ -51,6 +52,7 @@ from .common import (
immich_client_for,
immich_creds,
list_assets,
task_sources_for_widget,
valid_http_url,
webdav_creds_for,
)
@@ -255,6 +257,8 @@ def api_widget_config_save(
calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | None = Form(None),
# tasks
tasks_show_completed: bool | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -317,6 +321,11 @@ def api_widget_config_save(
# new unit label.
ccfg.weather_checked_at = 0.0
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:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -618,58 +627,107 @@ def api_widget_preview_calendar(
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")
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."""
"""The same cached merged 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")
if not task_sources_for_widget(db, widget):
raise HTTPException(400, "No task lists included on this widget yet")
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb)
return Response(content=png, media_type="image/png")
class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source
class TaskListSelectRequest(BaseModel):
user_id: int
calendar_key: str
calendar_label: str = ""
included: bool
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source")
def api_widget_tasks_source(
body: TasksSourceRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select")
def api_widget_task_list_select(
body: TaskListSelectRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
db: Session = Depends(get_db),
):
"""Points this tasks widget at one of the calling user's own CalDAV
calendars -- same owner-controls-their-own-data permission split as
calendar-select's included=True, since this is volunteering personal
calendar data, not a display setting a controller should get to pick
on someone else's behalf. None clears the source; clearing (unlike
setting) isn't ownership-gated -- like muting a shared calendar,
anyone linked to the frame can turn off a task list they'd rather
not see, but only its owner can point the widget at one of their
calendars to begin with."""
"""Include/exclude one CalDAV task list (calendar_key "caldav:<href>",
see FrameTaskList) on this tasks widget -- same one-sided permission
split as api_widget_calendar_select: turning a list ON requires being
its owner (nobody can add someone else's task list to a shared frame
for them), turning one OFF only requires being linked to the frame at
all, so anyone sharing the display can mute a list they'd rather not
see there even if they don't own it. Deliberately not
require_widget_control for the same reason."""
frame, widget = frame_widget
_require_widget_type(widget, "tasks")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.calendar_key is None:
cfg.user_id = None
cfg.calendar_key = None
cfg.cached = None
else:
cfg.user_id = user.id
cfg.calendar_key = body.calendar_key
cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key}
if body.included and body.user_id != user.id:
raise HTTPException(403, "Only a task list's owner can add it to a frame")
row = db.execute(
select(FrameTaskList).where(
FrameTaskList.widget_id == widget.id,
FrameTaskList.user_id == body.user_id,
FrameTaskList.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
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):
@@ -736,9 +794,9 @@ def api_widget_whiteboard_source(
"""Points this widget at one of the calling user's own WebDAV (or
reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
same owner-controls-their-own-data permission split as
api_widget_tasks_source: only the account owner can set the widget to
use it, but anyone linked to the frame can clear it, same as muting a
shared calendar."""
api_widget_task_list_select: only the account owner can set the
widget to use it, but anyone linked to the frame can clear it, same
as muting a shared calendar."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
user = require_user_api(request, db)
+44 -20
View File
@@ -7,7 +7,7 @@ import logging
import os
import statistics
import time
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
import httpx
@@ -26,6 +26,7 @@ from ..models import (
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
TaskWidgetConfig,
User,
@@ -565,31 +566,54 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
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]:
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
cadence as event merging), reading/writing TaskWidgetConfig (see
app/widgets/tasks.py). [] if no source is set, or the source user's
CalDAV credentials/calendar_key have gone missing (e.g. they
unlinked their account). A refetch failure keeps the last-known list
rather than going blank for one bad cycle, same reasoning as
get_or_refresh_weather_for_widget."""
"""Throttled multi-list merge-fetch cache (calendar_feed.
CHECK_INTERVAL_S, same cadence as event merging), reading/writing
TaskWidgetConfig (see app/widgets/tasks.py). [] if no list is
included yet. Same posture as get_or_refresh_calendar_events_for_
widget (which this otherwise mirrors closely), not weather's own
per-city stale-cache fallback: a broken list just contributes
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)
if not cfg.calendar_key or not cfg.user_id:
return []
now = time.time()
if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached
user = db.get(User, cfg.user_id)
key = cfg.calendar_key
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
return cfg.cached or []
href = key[len("caldav:"):]
try:
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
except caldav_client.CalDavError as e:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
return cfg.cached or []
sources = task_sources_for_widget(db, widget)
if not sources:
return []
completed_since = datetime.now(timezone.utc) - timedelta(hours=TASKS_COMPLETED_WINDOW_HOURS) \
if cfg.show_completed else None
tasks, summary = caldav_client.merge_tasks(sources, completed_since=completed_since)
if summary:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, summary)
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = tasks
+41 -20
View File
@@ -30,6 +30,7 @@ from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
TaskWidgetConfig,
User,
@@ -137,22 +138,41 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe
return result
def _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this tasks widget currently pulls from, and
its label -- for showing "using <name>'s Chores list" to everyone
linked, not just whoever set it. None if no source is configured."""
if not task_cfg.user_id or not task_cfg.calendar_key:
return None
user = db.get(User, task_cfg.user_id)
if user is None:
return None
label = task_cfg.calendar_key
for c in (user.calendar_caldav_calendars or []):
if f"caldav:{c['href']}" == task_cfg.calendar_key:
label = c.get("display_name") or label
break
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label,
"calendar_key": task_cfg.calendar_key}
def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user task-list list for the tasks dialog's "Included
task lists" section -- same shape as _calendar_users_for_widget,
restricted to CalDAV calendars only (no "ics" option: a plain ICS
subscription has no VTODO collection to speak of, see
caldav_client.fetch_tasks)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameTaskList]] = {}
for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(ftl.user_id, []).append(ftl)
result = []
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:
@@ -208,11 +228,12 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
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,
"tasks_source": _tasks_source_info(db, task_cfg),
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
})
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
// this widget's source, plus the rendered preview. Not a page-load
// Tasks widget dialog: per-user included-task-list checkboxes + color
// 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,
// 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) => {
// Each task list's own include/mute toggle -- auto-saves on change,
// not batched into the form below, since it's a data-sharing choice
// (see api_widget_task_list_select), not a widget-wide setting. Works
// 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 () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
const resp = await fetch(`${window.FRAME_API}/task-list-select`, {
method: 'POST',
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));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
showStatus(true, el.checked ? 'Task list included on this widget.' : 'Task list removed from this widget.');
loadTasksPreview();
} 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();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
document.getElementById('tasks-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
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);
loadTasksPreview();
+47 -28
View File
@@ -1,39 +1,58 @@
<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>
<h2 class="card-title">Included task lists</h2>
<p class="sub">Each linked person adds their own CalDAV task lists (set
up in <a href="/settings">Settings</a> -- a plain ICS subscription
has no task list) -- being linked here doesn't include anything
automatically. Anyone linked to this frame can mute a list they'd
rather not see here, even one they don't own; only its owner can add
it back.</p>
<ul class="calendar-user-list">
{% for u in task_users %}
<li>
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
{% if u.task_lists %}
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% 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 %}
</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>
{% endfor %}
{% elif u.is_self %}
<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>
{% else %}
<p class="sub" style="margin-top: 6px;">No task lists included.</p>
{% endif %}
</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">Recently completed</h2>
<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 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 --
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.
"""Tasks widget: a simple outstanding-task checklist merged from one or
more of its linked users' CalDAV task lists, in its own region -- split
out of the calendar widget's old week-view-only, single-list task list
(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
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."""
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
@@ -14,9 +23,8 @@ from PIL import Image
from sqlalchemy.orm import Session
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 ._shared import placeholder_image
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
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)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb)
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,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
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
migrate."""
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"):
conn.execute(text(f"DROP TABLE {table}"))
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"]
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."""
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
upgrading past both migrations" scenario, since both apply in the
same run_migrations() call here): 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 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:
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(
"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")}
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(
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]
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"}]
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.
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
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs", "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"):
conn.execute(text(f"DROP TABLE {table}"))
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"
permission pattern, repeated across calendar-select, tasks-source, and
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
permission pattern, repeated across calendar-select, task-list-select,
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,
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)."""
from __future__ import annotations
@@ -13,6 +14,7 @@ from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
FrameTaskList,
TaskWidgetConfig,
User,
Widget,
@@ -165,53 +167,139 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
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)
widget = _add_tasks_widget(db_session, frame)
client.cookies.clear()
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))
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}/tasks-source", json={"calendar_key": None},
headers=csrf_headers(client))
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 == 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
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id is None
assert cfg.calendar_key is None
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 True
def test_tasks_source_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/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
def test_task_list_select_bob_can_mute_alices_list(client, db_session):
"""Muting is a display-preference veto anyone linked gets, unlike
adding -- the one-sided half of this endpoint's permission split."""
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
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)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
alice_id = db_session.query(User).filter_by(username="alice").one().id
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
@@ -9,7 +9,7 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data
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
@@ -32,6 +32,18 @@ def _add_calendar_widget(db_session) -> 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):
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)
@@ -80,6 +92,21 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
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):
"""Fields not present in the POST are left untouched -- the whole
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/
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."""
call (get_or_refresh_tasks_for_widget, throttle/multi-list-merge/CalDAV
-fetch logic covered separately -- see test_widget_config_and_queue_
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
@@ -11,8 +14,6 @@ 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)
@@ -25,30 +26,44 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
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}])
def test_render_produces_a_correctly_sized_image_with_no_lists_included(db_session, monkeypatch):
"""No placeholder for "nothing included yet" -- same posture as
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."""
frame, widget = _make_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)
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: [])
def test_render_produces_a_correctly_sized_image_with_tasks(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
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)
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
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)
frame, widget = _make_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)