Let a tasks widget merge multiple task lists, checkbox+color like calendar
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:
+15
-9
@@ -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
|
||||
|
||||
+79
-12
@@ -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":
|
||||
continue
|
||||
due = component.get("DUE")
|
||||
tasks.append({
|
||||
"summary": str(component.get("SUMMARY") or "(untitled)"),
|
||||
"due": due.dt.isoformat() if due is not None else None,
|
||||
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")
|
||||
outstanding.append({
|
||||
"summary": summary,
|
||||
"due": due.dt.isoformat() if due is not None else None,
|
||||
"completed_at": None,
|
||||
})
|
||||
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,
|
||||
})
|
||||
tasks.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
|
||||
return tasks
|
||||
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tasksSourceClear = document.getElementById('tasks-source-clear');
|
||||
if (tasksSourceClear) {
|
||||
tasksSourceClear.addEventListener('click', clearTasksSource);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
{% 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>
|
||||
</section>
|
||||
|
||||
<div id="tasks-current-source">
|
||||
{% if tasks_source %}
|
||||
<p class="sub" style="margin-top: 10px;">
|
||||
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
|
||||
<strong>{{ tasks_source.label }}</strong> list.
|
||||
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if viewer_task_calendars %}
|
||||
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
|
||||
<ul class="calendar-user-list">
|
||||
{% for c in viewer_task_calendars %}
|
||||
<li class="checkbox-row" style="margin-top: 6px;">
|
||||
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
|
||||
{% if tasks_source and tasks_source.user_id == user.id and tasks_source.calendar_key == c.key %}checked{% endif %}>
|
||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
|
||||
task lists available -- set up a CalDAV account in
|
||||
<a href="/settings">Settings</a> first.</p>
|
||||
{% endif %}
|
||||
<section 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
@@ -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 = {}
|
||||
|
||||
@@ -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]
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user