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:
+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 = {}
|
||||
|
||||
Reference in New Issue
Block a user