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