Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist tied to a calendar's view and only inside its footprint. Tasks are now a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that can be placed and sized independently, same as photos/calendar/ whiteboard -- no separate "enabled" flag either, since being on the grid at all is the on/off switch, matching every other widget type. Migration 17 creates task_widget_configs, extracts any existing calendar widget's configured task source into a new sibling tasks widget (auto-placed in open grid space, source dropped+logged if truly none is left), then drops calendar_widget_configs' now-dead tasks_* columns in the same migration -- this project's usual same-migration- drop convention. Also handles the rarer case of a database jumping straight from before the widget system existed to after this split in one boot, via the legacy Frame.calendar_tasks_* columns. Verified live in the browser at desktop and mobile widths: adding a Tasks widget, its own dialog (task-list source picker + preview), and confirming the calendar widget's dialog no longer mentions tasks at all. Full test suite (180 tests, including new coverage for the widget render/actions, the migration's data-extraction path, and the permission-boundary shape for tasks-source) passes.
This commit is contained in:
+21
-15
@@ -1,9 +1,10 @@
|
|||||||
# Widget system
|
# Widget system
|
||||||
|
|
||||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||||
placed/sized widgets (photos/calendar/whiteboard), like arranging icons on
|
placed/sized widgets (photos/calendar/whiteboard/tasks), like arranging
|
||||||
an Android home screen. A frame can hold several widgets of the same type
|
icons on an Android home screen. A frame can hold several widgets of the
|
||||||
(e.g. two photo widgets pointed at different Immich albums side by side).
|
same type (e.g. two photo widgets pointed at different Immich albums side
|
||||||
|
by side).
|
||||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
||||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
||||||
@@ -19,17 +20,21 @@ a button press does.
|
|||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||||
(`"photos"` | `"calendar"` | `"whiteboard"`), `x`/`y`/`w`/`h` (grid
|
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"`), `x`/`y`/`w`/`h`
|
||||||
cells), `sort_order`. Widgets never overlap (enforced server-side in
|
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||||
already checked) -- that's what keeps compositing simple: no z-order,
|
already checked) -- that's what keeps compositing simple: no z-order,
|
||||||
no blending, just N independent regions pasted onto one shared canvas.
|
no blending, just N independent regions pasted onto one shared canvas.
|
||||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, each keyed by
|
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||||
`widget_id` with `ondelete="CASCADE"` -- rather than one wide table with
|
each keyed by `widget_id` with `ondelete="CASCADE"` -- rather than one
|
||||||
every type's mostly-irrelevant columns. `PhotoWidgetConfig` mirrors
|
wide table with every type's mostly-irrelevant columns. `PhotoWidgetConfig`
|
||||||
`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
|
||||||
|
`CalendarWidgetConfig` (a week-view-only task list); split into its own
|
||||||
|
widget type (migration 17) so a task list can be placed and sized
|
||||||
|
independent of any calendar's view/footprint.
|
||||||
- `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame
|
- `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame
|
||||||
can now have more than one independent calendar widget, each with its
|
can now have more than one independent calendar widget, each with its
|
||||||
own set of included calendars.
|
own set of included calendars.
|
||||||
@@ -50,16 +55,16 @@ orientation change rather than trying to remap coordinates.
|
|||||||
|
|
||||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||||
size-tier scaling), whiteboard 2x2. Enforced both client-side (UX, in the
|
size-tier scaling), whiteboard 2x2, tasks 2x2. Enforced both client-side
|
||||||
Layout tab's drag/resize canvas -- `static/frame_layout.js`) and
|
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||||
server-side (`routers/api_widgets.py`) -- the client is never trusted
|
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||||
alone.
|
alone.
|
||||||
|
|
||||||
## Rendering: one shared compositor
|
## Rendering: one shared compositor
|
||||||
|
|
||||||
`app/widgets/` is the render/action registry -- one module per
|
`app/widgets/` is the render/action registry -- one module per
|
||||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`), each
|
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`),
|
||||||
exposing:
|
each exposing:
|
||||||
|
|
||||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
||||||
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||||
@@ -69,7 +74,8 @@ exposing:
|
|||||||
bad moment doesn't blank the whole panel.
|
bad moment doesn't blank the whole panel.
|
||||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||||
for whiteboard).
|
for whiteboard). Empty for tasks -- a passive checklist on the same
|
||||||
|
throttled-refresh cadence as weather, nothing to advance/back/force.
|
||||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||||
assignment UI.
|
assignment UI.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Renders calendar frame mode's three views (agenda/week/month) into the
|
"""Renders calendar frame mode's three views (agenda/week/month), and the
|
||||||
panel's packed format, following image_pipeline.render_placeholder's own
|
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
|
||||||
precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
|
list used to be a calendar-widget-only week-view slot, split out into
|
||||||
_quantize/_transpose_and_pack every other renderer ends on.
|
its own widget type so it isn't tied to a calendar's view/footprint),
|
||||||
|
into the panel's packed format, following image_pipeline.
|
||||||
|
render_placeholder's own precedent: build an RGB canvas with
|
||||||
|
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
|
||||||
|
other renderer ends on.
|
||||||
|
|
||||||
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
||||||
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
||||||
@@ -523,33 +527,24 @@ 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,
|
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont) -> None:
|
||||||
margin: int = MARGIN) -> None:
|
|
||||||
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
|
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
|
||||||
glyph + due date (if any) + summary per outstanding task, same
|
glyph + due date (if any) + summary per outstanding task, same
|
||||||
header/rule/row-cap/truncation shape as _draw_agenda_day's event
|
header/rule/row-cap/truncation shape as _draw_agenda_day's event
|
||||||
list so the "week view, one slot replaced by tasks instead of a day"
|
list so the standalone tasks widget (see _build_tasks) reads as the
|
||||||
layout (see _build_week) reads as one consistent design rather than
|
same consistent design as everything else on-panel, not a
|
||||||
two different widgets bolted together. Reuses _draw_mixed_line so a
|
bolted-together look. Reuses _draw_mixed_line so a task summary with
|
||||||
task summary with emoji in it renders the same way an event
|
emoji in it renders the same way an event title's does."""
|
||||||
title's does.
|
|
||||||
|
|
||||||
`margin` defaults to the module-wide MARGIN (vertical layout's
|
|
||||||
stacked bands are as wide as the whole content region, same as
|
|
||||||
_draw_agenda_day's own sections) but a narrow horizontal-layout
|
|
||||||
column passes a much smaller one -- MARGIN on both sides of an
|
|
||||||
already-cramped ~150px week column left almost nothing for the
|
|
||||||
title text itself."""
|
|
||||||
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
|
||||||
draw_text(img, (text_x0, text_y0), "Tasks", title_font)
|
draw_text(img, (text_x0, text_y0), "Tasks", title_font)
|
||||||
y = text_y0 + title_font.size + 12
|
y = text_y0 + title_font.size + 12
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
||||||
y += 12
|
y += 12
|
||||||
|
|
||||||
row_h = body_font.size + 14
|
row_h = body_font.size + 14
|
||||||
max_rows = max(0, (y0 + h - margin - y) // row_h)
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||||
|
|
||||||
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)
|
||||||
@@ -642,16 +637,13 @@ _WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small"
|
|||||||
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||||
week_start: int, palette_rgb: list | None = None,
|
week_start: int, palette_rgb: list | None = None,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
|
days: int = 7, layout: str = "horizontal",
|
||||||
start_offset: int = 0) -> Image.Image:
|
start_offset: int = 0) -> Image.Image:
|
||||||
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
||||||
columns (layout="horizontal", the original fixed-at-7 behavior
|
columns (layout="horizontal", the original fixed-at-7 behavior
|
||||||
generalized) or stacked bands (layout="vertical", reusing
|
generalized) or stacked bands (layout="vertical", reusing
|
||||||
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
||||||
an arbitrary day count instead of a hardcoded 2). `tasks` (see
|
an arbitrary day count instead of a hardcoded 2).
|
||||||
routers/common.py's get_or_refresh_tasks), if not None, takes the
|
|
||||||
LAST slot instead of adding an extra one -- "N days" always means N
|
|
||||||
slots total, whether they're all days or N-1 days plus a task list.
|
|
||||||
|
|
||||||
At the default 7 days, the view anchors to week_start (a fixed
|
At the default 7 days, the view anchors to week_start (a fixed
|
||||||
weekday, "start on the most recent Monday") exactly like before --
|
weekday, "start on the most recent Monday") exactly like before --
|
||||||
@@ -668,7 +660,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
||||||
else:
|
else:
|
||||||
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
||||||
day_count = days - 1 if tasks is not None else days
|
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
|
|
||||||
if layout == "vertical":
|
if layout == "vertical":
|
||||||
@@ -677,7 +668,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
body_font = ImageFont.load_default(size=max(11, body_base - days))
|
body_font = ImageFont.load_default(size=max(11, body_base - days))
|
||||||
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
|
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
|
||||||
section_h = target_h // days
|
section_h = target_h // days
|
||||||
for i in range(day_count):
|
for i in range(days):
|
||||||
section_y0 = i * section_h
|
section_y0 = i * section_h
|
||||||
if i > 0:
|
if i > 0:
|
||||||
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
||||||
@@ -685,11 +676,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
|
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
|
||||||
title_font, body_font, owners_seen, palette_rgb,
|
title_font, body_font, owners_seen, palette_rgb,
|
||||||
weather_cities, weather_font, weather_units)
|
weather_cities, weather_font, weather_units)
|
||||||
if tasks is not None:
|
|
||||||
section_y0 = day_count * section_h
|
|
||||||
if day_count > 0:
|
|
||||||
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
|
||||||
_draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
||||||
@@ -699,7 +685,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
col_w = (target_w - MARGIN * 2) // days
|
col_w = (target_w - MARGIN * 2) // days
|
||||||
header_h = 44
|
header_h = 44
|
||||||
|
|
||||||
for col in range(day_count):
|
for col in range(days):
|
||||||
day = week_first_day + timedelta(days=col)
|
day = week_first_day + timedelta(days=col)
|
||||||
x0 = MARGIN + col * col_w
|
x0 = MARGIN + col * col_w
|
||||||
if col > 0:
|
if col > 0:
|
||||||
@@ -735,13 +721,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
chip_font, col_w - 20 - prefix_w)
|
chip_font, col_w - 20 - prefix_w)
|
||||||
y += row_h
|
y += row_h
|
||||||
|
|
||||||
if tasks is not None:
|
|
||||||
col = day_count
|
|
||||||
x0 = MARGIN + col * col_w
|
|
||||||
if col > 0:
|
|
||||||
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
|
|
||||||
_draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
|
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
@@ -814,7 +793,7 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
|
|||||||
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
|
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
|
||||||
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None,
|
week_days: int = 7, week_layout: str = "horizontal",
|
||||||
week_start_offset: int = 0) -> Image.Image:
|
week_start_offset: int = 0) -> Image.Image:
|
||||||
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||||
effective_view = view
|
effective_view = view
|
||||||
@@ -829,15 +808,14 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
|
|||||||
weather_cities, weather_units)
|
weather_cities, weather_units)
|
||||||
elif effective_view == "week":
|
elif effective_view == "week":
|
||||||
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
|
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
|
||||||
weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
weather_cities, weather_units, week_days, week_layout, week_start_offset)
|
||||||
elif effective_view == "month":
|
elif effective_view == "month":
|
||||||
# Never given weather or tasks -- no room for either at typical
|
# Never given weather -- no room for it at typical month-cell
|
||||||
# month-cell size, same reasoning that already keeps this view
|
# size, same reasoning that already keeps this view to density
|
||||||
# to density dots instead of literal event text (see
|
# dots instead of literal event text (see _build_month's own
|
||||||
# _build_month's own docstring). Colors are still passed
|
# docstring). Colors are still passed through, though -- that's
|
||||||
# through, though -- that's a different concern (legibility of
|
# a different concern (legibility of individual events) than
|
||||||
# individual events) than needing a whole extra strip/slot of
|
# needing a whole extra strip of content.
|
||||||
# content.
|
|
||||||
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
|
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
|
||||||
else:
|
else:
|
||||||
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||||
@@ -855,18 +833,16 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
|
|||||||
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
week_days: int = 7, week_layout: str = "horizontal",
|
week_days: int = 7, week_layout: str = "horizontal",
|
||||||
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
|
week_start_offset: int = 0) -> bytes:
|
||||||
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
|
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
|
||||||
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
|
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
|
||||||
invariant every other renderer honors. weather_cities is
|
invariant every other renderer honors. weather_cities is
|
||||||
routers/common.py's get_or_refresh_weather() cache, or None/[] to
|
routers/common.py's get_or_refresh_weather() cache, or None/[] to
|
||||||
omit the weather strip entirely (also always omitted for view ==
|
omit the weather strip entirely (also always omitted for view ==
|
||||||
"month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
|
"month")."""
|
||||||
the task list entirely -- only ever drawn for view == "week", see
|
|
||||||
_build_week."""
|
|
||||||
target_w, target_h = logical_render_size(orientation)
|
target_w, target_h = logical_render_size(orientation)
|
||||||
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
||||||
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
|
||||||
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)
|
||||||
@@ -877,13 +853,58 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
|
|||||||
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
week_days: int = 7, week_layout: str = "horizontal",
|
week_days: int = 7, week_layout: str = "horizontal",
|
||||||
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
|
week_start_offset: int = 0) -> bytes:
|
||||||
"""Same pipeline as render_calendar, but a normal browser-viewable
|
"""Same pipeline as render_calendar, but a normal browser-viewable
|
||||||
PNG in logical (upright) orientation -- mirrors
|
PNG in logical (upright) orientation -- mirrors
|
||||||
image_pipeline.render_preview_png's relationship to render_frame."""
|
image_pipeline.render_preview_png's relationship to render_frame."""
|
||||||
target_w, target_h = logical_render_size(orientation)
|
target_w, target_h = logical_render_size(orientation)
|
||||||
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
||||||
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
|
||||||
|
img = _apply_manage_overlay(img, manage)
|
||||||
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
quantized.convert("RGB").save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Standalone tasks widget (split out of the old calendar-widget-only
|
||||||
|
# week-view task list -- see models.TaskWidgetConfig) -----------------
|
||||||
|
|
||||||
|
_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:
|
||||||
|
"""A tasks widget's entire region is the checklist -- unlike the old
|
||||||
|
week-view slot, there's no day columns/header to share space with,
|
||||||
|
so this is just _draw_tasks over the whole box."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
|
||||||
|
title_font = ImageFont.load_default(size=title_size)
|
||||||
|
body_font = ImageFont.load_default(size=body_size)
|
||||||
|
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
||||||
|
manage: dict | None = None) -> bytes:
|
||||||
|
"""Renders the tasks widget full-panel to the panel's packed format.
|
||||||
|
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
|
||||||
|
every other renderer honors."""
|
||||||
|
target_w, target_h = logical_render_size(orientation)
|
||||||
|
img = _build_tasks(tasks, target_w, target_h)
|
||||||
|
img = _apply_manage_overlay(img, manage)
|
||||||
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
|
return _transpose_and_pack(quantized, orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
||||||
|
manage: dict | None = None) -> bytes:
|
||||||
|
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
|
||||||
|
in logical (upright) orientation -- mirrors render_calendar_preview_
|
||||||
|
png's relationship to render_calendar."""
|
||||||
|
target_w, target_h = logical_render_size(orientation)
|
||||||
|
img = _build_tasks(tasks, target_w, target_h)
|
||||||
img = _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()
|
||||||
|
|||||||
+4
-3
@@ -92,9 +92,10 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
|||||||
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
|
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
|
||||||
"""Same lock/refresh/commit dance as frame_locked, additionally
|
"""Same lock/refresh/commit dance as frame_locked, additionally
|
||||||
resolving and refreshing the widget's own per-type config row
|
resolving and refreshing the widget's own per-type config row
|
||||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see
|
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
|
||||||
models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame*
|
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
|
||||||
granularity -- the exact same per-frame threading.Lock frame_locked
|
still locks at *frame* granularity -- the exact same per-frame
|
||||||
|
threading.Lock frame_locked
|
||||||
uses, not a separate per-widget lock -- simplest, avoids a new class
|
uses, not a separate per-widget lock -- simplest, avoids a new class
|
||||||
of multi-lock deadlock bugs, and this project's actual concurrency
|
of multi-lock deadlock bugs, and this project's actual concurrency
|
||||||
needs are tiny (a handful of users per household frame).
|
needs are tiny (a handful of users per household frame).
|
||||||
|
|||||||
+4
-1
@@ -22,11 +22,14 @@ GRID_SHORT = 5
|
|||||||
# the placement UI and server-side (routers/api_widgets.py). A calendar
|
# the placement UI and server-side (routers/api_widgets.py). A calendar
|
||||||
# widget crammed into 1x1 would be illegible regardless of size-tier
|
# widget crammed into 1x1 would be illegible regardless of size-tier
|
||||||
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
||||||
# worth looking at; photos can go as small as a single cell.
|
# worth looking at; photos can go as small as a single cell; tasks needs
|
||||||
|
# enough width for a due-date prefix plus a couple words of summary
|
||||||
|
# without truncating on every row.
|
||||||
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||||
"photos": (1, 1),
|
"photos": (1, 1),
|
||||||
"calendar": (3, 2),
|
"calendar": (3, 2),
|
||||||
"whiteboard": (2, 2),
|
"whiteboard": (2, 2),
|
||||||
|
"tasks": (2, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||||
|
|||||||
+158
-5
@@ -27,6 +27,7 @@ from .models import (
|
|||||||
FrameButtonAction,
|
FrameButtonAction,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
ServerSettings,
|
ServerSettings,
|
||||||
|
TaskWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
@@ -333,6 +334,110 @@ def _migration_16(conn) -> None:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_17(conn) -> None:
|
||||||
|
"""Splits the calendar widget's old week-view-only task list out into
|
||||||
|
its own standalone widget type (see models.TaskWidgetConfig,
|
||||||
|
app/widgets/tasks.py) -- a task list is no longer tied to a
|
||||||
|
calendar's view or footprint, and can be placed/sized on its own.
|
||||||
|
|
||||||
|
Every calendar_widget_configs row that still has a task source
|
||||||
|
configured gets a new sibling `tasks` widget carrying that source
|
||||||
|
over, auto-placed in whatever open grid space is left on its frame
|
||||||
|
(same find_open_rect logic a manual "add widget" uses; if truly none
|
||||||
|
is left, the source is dropped and logged -- rare enough, and with
|
||||||
|
no interactive way to ask during a boot-time migration, that this is
|
||||||
|
an acceptable edge case). calendar_widget_configs then drops its now
|
||||||
|
-dead tasks_* columns -- this project's usual same-migration-drop
|
||||||
|
convention (see docs/widgets.md's Known Gaps for the one deliberate,
|
||||||
|
much-larger-blast-radius exception)."""
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE task_widget_configs ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||||
|
"calendar_key TEXT, "
|
||||||
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"cached TEXT)"
|
||||||
|
))
|
||||||
|
|
||||||
|
rows = conn.execute(text(
|
||||||
|
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
|
||||||
|
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
|
||||||
|
"FROM calendar_widget_configs cwc "
|
||||||
|
"JOIN widgets w ON w.id = cwc.widget_id "
|
||||||
|
"JOIN frames f ON f.id = w.frame_id "
|
||||||
|
"WHERE cwc.tasks_calendar_key IS NOT NULL"
|
||||||
|
)).mappings().all()
|
||||||
|
|
||||||
|
skipped = 0
|
||||||
|
now = time.time()
|
||||||
|
for row in rows:
|
||||||
|
existing = conn.execute(text(
|
||||||
|
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
|
||||||
|
), {"frame_id": row["frame_id"]}).all()
|
||||||
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||||
|
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
|
||||||
|
if rect is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
x, y, w, h = rect
|
||||||
|
max_sort = conn.execute(text(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
||||||
|
), {"frame_id": row["frame_id"]}).scalar()
|
||||||
|
result = conn.execute(text(
|
||||||
|
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
|
||||||
|
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
|
||||||
|
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
||||||
|
"sort_order": max_sort + 1, "created_at": now})
|
||||||
|
new_widget_id = result.lastrowid
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
|
||||||
|
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
|
||||||
|
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
|
||||||
|
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
|
||||||
|
"cached": row["tasks_cached"]})
|
||||||
|
|
||||||
|
if skipped:
|
||||||
|
logger.warning(
|
||||||
|
"%d calendar widget(s) had a task list configured but no open grid space for a "
|
||||||
|
"standalone tasks widget -- their task source was dropped", skipped
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rebuild calendar_widget_configs without the now-dead tasks_*
|
||||||
|
# columns -- SQLite can't drop tasks_user_id directly (it's part of
|
||||||
|
# an FK constraint), same situation frame_calendars hit in
|
||||||
|
# _migration_9, same rebuild-create-copy-drop-rename fix.
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE calendar_widget_configs_new ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
||||||
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"cached_events TEXT, "
|
||||||
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
||||||
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||||
|
"weather_cities TEXT, "
|
||||||
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"weather_cached TEXT, "
|
||||||
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
||||||
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
||||||
|
"week_start_offset INTEGER NOT NULL DEFAULT 0)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO calendar_widget_configs_new "
|
||||||
|
"(widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
||||||
|
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
||||||
|
"week_days, week_layout, week_start_offset) "
|
||||||
|
"SELECT widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
||||||
|
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
||||||
|
"week_days, week_layout, week_start_offset "
|
||||||
|
"FROM calendar_widget_configs"
|
||||||
|
))
|
||||||
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||||
|
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -350,6 +455,7 @@ MIGRATIONS = [
|
|||||||
(14, _migration_14),
|
(14, _migration_14),
|
||||||
(15, _migration_15),
|
(15, _migration_15),
|
||||||
(16, _migration_16),
|
(16, _migration_16),
|
||||||
|
(17, _migration_17),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -518,11 +624,26 @@ 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_enabled=frame.calendar_tasks_enabled,
|
# tasks_* deliberately not carried over -- see _task_config_from_frame,
|
||||||
tasks_user_id=frame.calendar_tasks_user_id,
|
# a sibling standalone widget now, not part of this config.
|
||||||
tasks_calendar_key=frame.calendar_tasks_calendar_key,
|
)
|
||||||
tasks_checked_at=frame.calendar_tasks_checked_at,
|
|
||||||
tasks_cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
|
||||||
|
def _task_config_from_frame(frame: Frame, widget_id: int) -> TaskWidgetConfig:
|
||||||
|
"""Only ever called for a frame whose legacy calendar_tasks_* columns
|
||||||
|
(see Frame's own docstring on those -- a dead pre-widget-system
|
||||||
|
field set, same status as calendar_photo_inlay below) still carry a
|
||||||
|
configured source -- i.e. a database jumping straight from before
|
||||||
|
the widget system existed to after tasks became its own widget type
|
||||||
|
in a single upgrade, skipping the intermediate period where it would
|
||||||
|
have lived on CalendarWidgetConfig instead (see _migration_17's own
|
||||||
|
extraction of *that* case)."""
|
||||||
|
return TaskWidgetConfig(
|
||||||
|
widget_id=widget_id,
|
||||||
|
user_id=frame.calendar_tasks_user_id,
|
||||||
|
calendar_key=frame.calendar_tasks_calendar_key,
|
||||||
|
checked_at=frame.calendar_tasks_checked_at,
|
||||||
|
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -556,6 +677,33 @@ def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) ->
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
||||||
|
"""Only relevant for a database jumping straight from before the
|
||||||
|
widget system existed to after tasks became its own widget type in
|
||||||
|
one upgrade (see _task_config_from_frame) -- frame.calendar_tasks_*
|
||||||
|
is the dead legacy field set otherwise. Auto-placed in whatever open
|
||||||
|
space is left after the widget(s) above it in _backfill_frame_
|
||||||
|
widgets claimed theirs, same find_open_rect logic a manual "add
|
||||||
|
widget" uses; silently dropped (logged) if none fits, same as this
|
||||||
|
migration having nowhere else to put it either."""
|
||||||
|
if not frame.calendar_tasks_calendar_key:
|
||||||
|
return
|
||||||
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||||
|
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
|
||||||
|
if rect is None:
|
||||||
|
logger.warning(
|
||||||
|
"Frame %d had a legacy task list configured but no open grid space for a "
|
||||||
|
"standalone tasks widget during backfill -- its task source was dropped", frame.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
x, y, w, h = rect
|
||||||
|
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
||||||
|
sort_order=next_sort_order, created_at=time.time())
|
||||||
|
db.add(task_widget)
|
||||||
|
db.flush()
|
||||||
|
db.add(_task_config_from_frame(frame, task_widget.id))
|
||||||
|
|
||||||
|
|
||||||
def _backfill_frame_widgets(db, frame: Frame) -> None:
|
def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||||
cols, rows = grid.grid_dims(frame.orientation)
|
cols, rows = grid.grid_dims(frame.orientation)
|
||||||
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
|
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
|
||||||
@@ -575,6 +723,9 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
|||||||
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
||||||
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
||||||
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||||
|
_maybe_add_legacy_tasks_widget(
|
||||||
|
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
||||||
@@ -588,6 +739,8 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
|||||||
elif mode == "whiteboard":
|
elif mode == "whiteboard":
|
||||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||||
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
||||||
|
if mode == "calendar":
|
||||||
|
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_widgets_backfilled() -> None:
|
def _ensure_widgets_backfilled() -> None:
|
||||||
|
|||||||
+37
-11
@@ -407,7 +407,7 @@ class Widget(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard"
|
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
|
||||||
x: Mapped[int] = mapped_column(Integer)
|
x: Mapped[int] = mapped_column(Integer)
|
||||||
y: Mapped[int] = mapped_column(Integer)
|
y: Mapped[int] = mapped_column(Integer)
|
||||||
w: Mapped[int] = mapped_column(Integer)
|
w: Mapped[int] = mapped_column(Integer)
|
||||||
@@ -450,9 +450,12 @@ class CalendarWidgetConfig(Base):
|
|||||||
fields that used to live as calendar_* columns directly on Frame,
|
fields that used to live as calendar_* columns directly on Frame,
|
||||||
minus calendar_photo_inlay (dropped: arbitrary widget placement
|
minus calendar_photo_inlay (dropped: arbitrary widget placement
|
||||||
subsumes what a fixed 50/50 inlay split did, so it's not a special
|
subsumes what a fixed 50/50 inlay split did, so it's not a special
|
||||||
case anymore, just place a photo widget alongside). "Included
|
case anymore, just place a photo widget alongside) and minus
|
||||||
calendars" is its own table (FrameCalendar), widget_id-keyed so each
|
tasks_* (also dropped: split out into its own standalone widget
|
||||||
calendar widget on a frame has its own independent set."""
|
type, see TaskWidgetConfig, so a task list isn't tied to a
|
||||||
|
calendar's week view/footprint anymore). "Included calendars" is
|
||||||
|
its own table (FrameCalendar), widget_id-keyed so each calendar
|
||||||
|
widget on a frame has its own independent set."""
|
||||||
|
|
||||||
__tablename__ = "calendar_widget_configs"
|
__tablename__ = "calendar_widget_configs"
|
||||||
|
|
||||||
@@ -471,13 +474,35 @@ class CalendarWidgetConfig(Base):
|
|||||||
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
||||||
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
tasks_user_id: Mapped[int | None] = mapped_column(
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
class TaskWidgetConfig(Base):
|
||||||
)
|
"""One tasks widget's settings + cached-fetch state -- split out of
|
||||||
tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
CalendarWidgetConfig (which used to carry these as tasks_* columns,
|
||||||
tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
a week-view-only task list bolted onto a calendar widget) so a task
|
||||||
tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
list can be placed and sized on its own, independent of any
|
||||||
|
calendar's view/footprint. No separate "enabled" flag -- unlike the
|
||||||
|
old bolted-on version, the widget's mere presence on the grid is the
|
||||||
|
on/off switch, same as every other widget type.
|
||||||
|
|
||||||
|
CalDAV only (a task list is a VTODO collection, not something a
|
||||||
|
plain ICS subscription meaningfully has); source is one specific
|
||||||
|
linked user's own CalDAV calendar, same owner-controls-their-own-
|
||||||
|
data permission split as FrameCalendar. user_id SET NULL on the
|
||||||
|
user's deletion clears the source rather than leaving a dangling
|
||||||
|
reference (checked_at isn't reset by that, but the next refresh
|
||||||
|
attempt finds no source and just returns [])."""
|
||||||
|
|
||||||
|
__tablename__ = "task_widget_configs"
|
||||||
|
|
||||||
|
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
|
||||||
|
# already filtered to outstanding (not-completed) tasks and sorted
|
||||||
|
# by due date -- see caldav_client.fetch_tasks.
|
||||||
|
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
|
||||||
class WhiteboardWidgetConfig(Base):
|
class WhiteboardWidgetConfig(Base):
|
||||||
@@ -500,6 +525,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
|||||||
"photos": PhotoWidgetConfig,
|
"photos": PhotoWidgetConfig,
|
||||||
"calendar": CalendarWidgetConfig,
|
"calendar": CalendarWidgetConfig,
|
||||||
"whiteboard": WhiteboardWidgetConfig,
|
"whiteboard": WhiteboardWidgetConfig,
|
||||||
|
"tasks": TaskWidgetConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Everything scoped to one specific widget rather than "the frame":
|
"""Everything scoped to one specific widget rather than "the frame":
|
||||||
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
|
||||||
whiteboard source, and their preview endpoints. Split out of
|
source, 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"
|
||||||
@@ -35,6 +35,7 @@ from ..models import (
|
|||||||
Frame,
|
Frame,
|
||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
|
TaskWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
WIDGET_CONFIG_MODELS,
|
WIDGET_CONFIG_MODELS,
|
||||||
Widget,
|
Widget,
|
||||||
@@ -254,7 +255,6 @@ 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),
|
||||||
calendar_tasks_enabled: 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,8 +317,6 @@ 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
|
||||||
if calendar_tasks_enabled is not None:
|
|
||||||
ccfg.tasks_enabled = calendar_tasks_enabled
|
|
||||||
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"}
|
||||||
@@ -506,7 +504,7 @@ def api_widget_preview_rendered(
|
|||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
# --- Calendar: inclusion/color/tasks/weather/preview --------------------
|
# --- Calendar: inclusion/color/weather/preview ---------------------------
|
||||||
|
|
||||||
class CalendarSelectRequest(BaseModel):
|
class CalendarSelectRequest(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
@@ -609,21 +607,37 @@ def api_widget_preview_calendar(
|
|||||||
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
||||||
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
||||||
tasks = (
|
|
||||||
get_or_refresh_tasks_for_widget(db, frame, widget)
|
|
||||||
if (ccfg.view == "week" and ccfg.tasks_enabled) else None
|
|
||||||
)
|
|
||||||
png = calendar_render.render_calendar_preview_png(
|
png = calendar_render.render_calendar_preview_png(
|
||||||
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
||||||
week_start=ccfg.week_start,
|
week_start=ccfg.week_start,
|
||||||
weather_cities=weather_cities, weather_units=ccfg.weather_units,
|
weather_cities=weather_cities, weather_units=ccfg.weather_units,
|
||||||
week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
|
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
|
||||||
week_start_offset=ccfg.week_start_offset,
|
week_start_offset=ccfg.week_start_offset,
|
||||||
)
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tasks: source/preview ------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
|
||||||
|
def api_widget_preview_tasks(
|
||||||
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""The same cached task list a live device render would use, same
|
||||||
|
"reflects what's currently saved" convention as the other preview
|
||||||
|
endpoints."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "tasks")
|
||||||
|
tcfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
|
if not tcfg.calendar_key or not tcfg.user_id:
|
||||||
|
raise HTTPException(400, "No task list configured on this widget yet")
|
||||||
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||||
|
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb)
|
||||||
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
class TasksSourceRequest(BaseModel):
|
class TasksSourceRequest(BaseModel):
|
||||||
calendar_key: str | None # None clears the source
|
calendar_key: str | None # None clears the source
|
||||||
|
|
||||||
@@ -634,27 +648,27 @@ def api_widget_tasks_source(
|
|||||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Points this widget's week-view task list at one of the calling
|
"""Points this tasks widget at one of the calling user's own CalDAV
|
||||||
user's own CalDAV calendars -- same owner-controls-their-own-data
|
calendars -- same owner-controls-their-own-data permission split as
|
||||||
permission split as calendar-select's included=True, since this is
|
calendar-select's included=True, since this is volunteering personal
|
||||||
volunteering personal calendar data, not a display setting a
|
calendar data, not a display setting a controller should get to pick
|
||||||
controller should get to pick on someone else's behalf. None clears
|
on someone else's behalf. None clears the source; clearing (unlike
|
||||||
the source; clearing (unlike setting) isn't ownership-gated -- like
|
setting) isn't ownership-gated -- like muting a shared calendar,
|
||||||
muting a shared calendar, anyone linked to the frame can turn off a
|
anyone linked to the frame can turn off a task list they'd rather
|
||||||
task list they'd rather not see, but only its owner can point the
|
not see, but only its owner can point the widget at one of their
|
||||||
widget at one of their calendars to begin with."""
|
calendars to begin with."""
|
||||||
frame, widget = frame_widget
|
frame, widget = frame_widget
|
||||||
_require_widget_type(widget, "calendar")
|
_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):
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||||
if body.calendar_key is None:
|
if body.calendar_key is None:
|
||||||
cfg.tasks_user_id = None
|
cfg.user_id = None
|
||||||
cfg.tasks_calendar_key = None
|
cfg.calendar_key = None
|
||||||
cfg.tasks_cached = None
|
cfg.cached = None
|
||||||
else:
|
else:
|
||||||
cfg.tasks_user_id = user.id
|
cfg.user_id = user.id
|
||||||
cfg.tasks_calendar_key = body.calendar_key
|
cfg.calendar_key = body.calendar_key
|
||||||
cfg.tasks_checked_at = 0.0 # pick up the change promptly
|
cfg.checked_at = 0.0 # pick up the change promptly
|
||||||
return {"status": "saved", "calendar_key": body.calendar_key}
|
return {"status": "saved", "calendar_key": body.calendar_key}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ..models import (
|
|||||||
FrameButtonAction,
|
FrameButtonAction,
|
||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
|
TaskWidgetConfig,
|
||||||
User,
|
User,
|
||||||
Widget,
|
Widget,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
@@ -566,33 +567,33 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
|||||||
|
|
||||||
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 task-list cache (calendar_feed.CHECK_INTERVAL_S, same
|
||||||
cadence as event merging), reading/writing CalendarWidgetConfig (see
|
cadence as event merging), reading/writing TaskWidgetConfig (see
|
||||||
app/widgets/calendar.py). [] if tasks are off, no source is set, or
|
app/widgets/tasks.py). [] if no source is set, or the source user's
|
||||||
the source user's CalDAV credentials/calendar_key have gone missing
|
CalDAV credentials/calendar_key have gone missing (e.g. they
|
||||||
(e.g. they unlinked their account). A refetch failure keeps the
|
unlinked their account). A refetch failure keeps the last-known list
|
||||||
last-known list rather than going blank for one bad cycle, same
|
rather than going blank for one bad cycle, same reasoning as
|
||||||
reasoning as get_or_refresh_weather_for_widget."""
|
get_or_refresh_weather_for_widget."""
|
||||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
cfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
|
if not cfg.calendar_key or not cfg.user_id:
|
||||||
return []
|
return []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if cfg.tasks_cached is not None and now - cfg.tasks_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.tasks_cached
|
return cfg.cached
|
||||||
|
|
||||||
user = db.get(User, cfg.tasks_user_id)
|
user = db.get(User, cfg.user_id)
|
||||||
key = cfg.tasks_calendar_key
|
key = cfg.calendar_key
|
||||||
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
|
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
|
||||||
return cfg.tasks_cached or []
|
return cfg.cached or []
|
||||||
href = key[len("caldav:"):]
|
href = key[len("caldav:"):]
|
||||||
try:
|
try:
|
||||||
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
|
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
|
||||||
except caldav_client.CalDavError as e:
|
except caldav_client.CalDavError as e:
|
||||||
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
|
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
|
||||||
return cfg.tasks_cached or []
|
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.tasks_cached = tasks
|
locked_cfg.cached = tasks
|
||||||
locked_cfg.tasks_checked_at = now
|
locked_cfg.checked_at = now
|
||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from ..models import (
|
|||||||
Frame,
|
Frame,
|
||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
|
TaskWidgetConfig,
|
||||||
User,
|
User,
|
||||||
UserFrame,
|
UserFrame,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
@@ -136,22 +137,22 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
|
def _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None:
|
||||||
"""Whose CalDAV calendar this widget's week-view task list currently
|
"""Whose CalDAV calendar this tasks widget currently pulls from, and
|
||||||
pulls from, and its label -- for showing "using <name>'s Chores
|
its label -- for showing "using <name>'s Chores list" to everyone
|
||||||
list" to everyone linked, not just whoever set it. None if no
|
linked, not just whoever set it. None if no source is configured."""
|
||||||
source is configured."""
|
if not task_cfg.user_id or not task_cfg.calendar_key:
|
||||||
if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
|
|
||||||
return None
|
return None
|
||||||
user = db.get(User, calendar_cfg.tasks_user_id)
|
user = db.get(User, task_cfg.user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
return None
|
return None
|
||||||
label = calendar_cfg.tasks_calendar_key
|
label = task_cfg.calendar_key
|
||||||
for c in (user.calendar_caldav_calendars or []):
|
for c in (user.calendar_caldav_calendars or []):
|
||||||
if f"caldav:{c['href']}" == calendar_cfg.tasks_calendar_key:
|
if f"caldav:{c['href']}" == task_cfg.calendar_key:
|
||||||
label = c.get("display_name") or label
|
label = c.get("display_name") or label
|
||||||
break
|
break
|
||||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
|
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label,
|
||||||
|
"calendar_key": task_cfg.calendar_key}
|
||||||
|
|
||||||
|
|
||||||
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
|
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
|
||||||
@@ -195,7 +196,6 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
|
|
||||||
if widget.widget_type == "calendar":
|
if widget.widget_type == "calendar":
|
||||||
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
|
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||||
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
|
|
||||||
return templates.TemplateResponse("_widget_dialog_calendar.html", {
|
return templates.TemplateResponse("_widget_dialog_calendar.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
|
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
|
||||||
"calendar_views": CALENDAR_VIEW_LABELS,
|
"calendar_views": CALENDAR_VIEW_LABELS,
|
||||||
@@ -204,8 +204,15 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
"calendar_color_labels": PALETTE_LABELS,
|
"calendar_color_labels": PALETTE_LABELS,
|
||||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||||
"palette_to_hex": palette_to_hex,
|
"palette_to_hex": palette_to_hex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if widget.widget_type == "tasks":
|
||||||
|
task_cfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
|
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
|
||||||
|
return templates.TemplateResponse("_widget_dialog_tasks.html", {
|
||||||
|
"request": request, "frame": frame, "widget": widget, "user": user,
|
||||||
"viewer_task_calendars": viewer_task_calendars,
|
"viewer_task_calendars": viewer_task_calendars,
|
||||||
"tasks_source": _tasks_source_info(db, calendar_cfg),
|
"tasks_source": _tasks_source_info(db, task_cfg),
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "whiteboard":
|
if widget.widget_type == "whiteboard":
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
// Shared display names for widget_type, everywhere one shows up in the
|
// Shared display names for widget_type, everywhere one shows up in the
|
||||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' };
|
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks' };
|
||||||
|
|
||||||
function showStatus(ok, message) {
|
function showStatus(ok, message) {
|
||||||
// While a <dialog> is open, its own .dialog-result container gets the
|
// While a <dialog> is open, its own .dialog-result container gets the
|
||||||
|
|||||||
@@ -280,12 +280,18 @@ window.addEventListener('resize', () => {
|
|||||||
// a frame can now have several widgets of the same type, so "the
|
// a frame can now have several widgets of the same type, so "the
|
||||||
// Calendar tab" stopped meaning anything unambiguous.
|
// Calendar tab" stopped meaning anything unambiguous.
|
||||||
|
|
||||||
// widget_dialog_{photos,calendar,whiteboard}.js each define an
|
// widget_dialog_{photos,calendar,whiteboard,tasks}.js each define an
|
||||||
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
|
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
|
||||||
// frame_layout.html, since which one runs depends on which widget's gear
|
// frame_layout.html, since which one runs depends on which widget's gear
|
||||||
// icon was clicked).
|
// icon was clicked).
|
||||||
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog };
|
const DIALOG_INIT = {
|
||||||
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog };
|
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||||
|
tasks: initTasksDialog,
|
||||||
|
};
|
||||||
|
const DIALOG_CLOSE = {
|
||||||
|
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||||
|
tasks: closeTasksDialog,
|
||||||
|
};
|
||||||
|
|
||||||
let openDialogWidgetType = null;
|
let openDialogWidgetType = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Calendar widget dialog: view/week-start settings, per-user opt-in,
|
// Calendar widget dialog: view/week-start settings, per-user opt-in,
|
||||||
// weather, tasks, and the rendered preview. Not a page-load script --
|
// weather, and the rendered preview. Not a page-load script --
|
||||||
// frame_layout.js fetches this widget's dialog HTML fragment, injects
|
// frame_layout.js fetches this widget's dialog HTML fragment, injects
|
||||||
// it into the shared <dialog>, points window.FRAME_API at this specific
|
// it into the shared <dialog>, points window.FRAME_API at this specific
|
||||||
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
|
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
|
||||||
@@ -64,48 +64,6 @@ async function removeWeatherCity(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) return; // matches the template's no-tasks_source branch: nothing rendered
|
|
||||||
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; });
|
|
||||||
loadCalendarPreview();
|
|
||||||
} catch (e) {
|
|
||||||
showStatus(false, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCalendarPreview() {
|
function loadCalendarPreview() {
|
||||||
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
|
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
|
||||||
}
|
}
|
||||||
@@ -237,50 +195,6 @@ function initCalendarDialog() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
|
|
||||||
const el = e.target;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error(await apiError(resp));
|
|
||||||
showStatus(true, 'Saved.');
|
|
||||||
loadCalendarPreview();
|
|
||||||
} catch (e) {
|
|
||||||
el.checked = !el.checked;
|
|
||||||
showStatus(false, e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Choosing one of your own CalDAV task lists as this widget's source --
|
|
||||||
// owner-only (see api_widget_tasks_source), so these radios only ever
|
|
||||||
// render for the viewer's own calendars anyway.
|
|
||||||
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
|
|
||||||
el.addEventListener('change', async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ calendar_key: el.dataset.key }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error(await apiError(resp));
|
|
||||||
showStatus(true, 'Task list saved.');
|
|
||||||
const labelEl = el.closest('li').querySelector('label');
|
|
||||||
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
|
|
||||||
loadCalendarPreview();
|
|
||||||
} catch (e) {
|
|
||||||
showStatus(false, e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const tasksSourceClear = document.getElementById('tasks-source-clear');
|
|
||||||
if (tasksSourceClear) {
|
|
||||||
tasksSourceClear.addEventListener('click', clearTasksSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||||
loadCalendarPreview();
|
loadCalendarPreview();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Tasks widget dialog: pick one of the viewer's own CalDAV task lists as
|
||||||
|
// this widget's source, plus the rendered preview. Not a page-load
|
||||||
|
// script -- frame_layout.js fetches this widget's dialog HTML fragment,
|
||||||
|
// injects it into the shared <dialog>, points window.FRAME_API at this
|
||||||
|
// specific widget (/api/frames/{id}/widgets/{widget_id}), then calls
|
||||||
|
// initTasksDialog().
|
||||||
|
|
||||||
|
// Rewrites #tasks-current-source in place instead of telling the user to
|
||||||
|
// reload -- the API always assigns a successful "set" to the caller
|
||||||
|
// (see api_widget_tasks_source), so after either action we already know
|
||||||
|
// exactly what the new state is without asking the server again.
|
||||||
|
function renderTasksCurrentSource(label) {
|
||||||
|
const container = document.getElementById('tasks-current-source');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!label) {
|
||||||
|
container.innerHTML = '<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'sub';
|
||||||
|
p.style.marginTop = '10px';
|
||||||
|
p.append('Currently using your ');
|
||||||
|
const labelEl = document.createElement('strong');
|
||||||
|
labelEl.textContent = label;
|
||||||
|
p.append(labelEl, ' list. ');
|
||||||
|
const clearBtn = document.createElement('button');
|
||||||
|
clearBtn.type = 'button';
|
||||||
|
clearBtn.className = 'btn-inline secondary';
|
||||||
|
clearBtn.id = 'tasks-source-clear';
|
||||||
|
clearBtn.textContent = 'Clear';
|
||||||
|
clearBtn.addEventListener('click', clearTasksSource);
|
||||||
|
p.append(clearBtn);
|
||||||
|
container.append(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTasksSource() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ calendar_key: null }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Task list cleared.');
|
||||||
|
renderTasksCurrentSource(null);
|
||||||
|
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
|
||||||
|
loadTasksPreview();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTasksPreview() {
|
||||||
|
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTasksDialog() {
|
||||||
|
// Choosing one of your own CalDAV task lists as this widget's source --
|
||||||
|
// owner-only (see api_widget_tasks_source), so these radios only ever
|
||||||
|
// render for the viewer's own calendars anyway.
|
||||||
|
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
|
||||||
|
el.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ calendar_key: el.dataset.key }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Task list saved.');
|
||||||
|
const labelEl = el.closest('li').querySelector('label');
|
||||||
|
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
|
||||||
|
loadTasksPreview();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasksSourceClear = document.getElementById('tasks-source-clear');
|
||||||
|
if (tasksSourceClear) {
|
||||||
|
tasksSourceClear.addEventListener('click', clearTasksSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||||
|
loadTasksPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTasksDialog() {
|
||||||
|
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||||
|
}
|
||||||
@@ -127,44 +127,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
|
||||||
<h2 class="card-title">Tasks</h2>
|
|
||||||
<p class="sub">Week view only -- takes the place of one day slot
|
|
||||||
instead of adding an extra one.</p>
|
|
||||||
<div class="checkbox-row" id="tasks-enabled-row">
|
|
||||||
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg.tasks_enabled %}checked{% endif %}>
|
|
||||||
<label for="tasks_enabled">Show a task list</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tasks-current-source">
|
|
||||||
{% if tasks_source %}
|
|
||||||
<p class="sub" style="margin-top: 10px;">
|
|
||||||
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
|
|
||||||
<strong>{{ tasks_source.label }}</strong> list.
|
|
||||||
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if viewer_task_calendars %}
|
|
||||||
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
|
|
||||||
<ul class="calendar-user-list">
|
|
||||||
{% for c in viewer_task_calendars %}
|
|
||||||
<li class="checkbox-row" style="margin-top: 6px;">
|
|
||||||
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
|
|
||||||
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
|
|
||||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
|
|
||||||
task lists available -- set up a CalDAV account in
|
|
||||||
<a href="/settings">Settings</a> first (a plain ICS subscription
|
|
||||||
doesn't carry tasks).</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<h2 class="dialog-title">Tasks widget</h2>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Task list source</h2>
|
||||||
|
<p class="sub">A simple outstanding-task checklist. CalDAV only -- a
|
||||||
|
task list is a VTODO collection, which a plain ICS subscription
|
||||||
|
doesn't carry.</p>
|
||||||
|
|
||||||
|
<div id="tasks-current-source">
|
||||||
|
{% if tasks_source %}
|
||||||
|
<p class="sub" style="margin-top: 10px;">
|
||||||
|
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
|
||||||
|
<strong>{{ tasks_source.label }}</strong> list.
|
||||||
|
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if viewer_task_calendars %}
|
||||||
|
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
|
||||||
|
<ul class="calendar-user-list">
|
||||||
|
{% for c in viewer_task_calendars %}
|
||||||
|
<li class="checkbox-row" style="margin-top: 6px;">
|
||||||
|
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
|
||||||
|
{% if tasks_source and tasks_source.user_id == user.id and tasks_source.calendar_key == c.key %}checked{% endif %}>
|
||||||
|
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
|
||||||
|
task lists available -- set up a CalDAV account in
|
||||||
|
<a href="/settings">Settings</a> first.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Preview</h2>
|
||||||
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
<img class="preview-img" id="tasks-preview" alt="Tasks preview">
|
||||||
|
<button type="button" class="secondary" id="tasks-preview-refresh">Refresh now</button>
|
||||||
|
</section>
|
||||||
@@ -58,5 +58,6 @@
|
|||||||
<script src="/static/queue.js"></script>
|
<script src="/static/queue.js"></script>
|
||||||
<script src="/static/widget_dialog_calendar.js"></script>
|
<script src="/static/widget_dialog_calendar.js"></script>
|
||||||
<script src="/static/widget_dialog_whiteboard.js"></script>
|
<script src="/static/widget_dialog_whiteboard.js"></script>
|
||||||
|
<script src="/static/widget_dialog_tasks.js"></script>
|
||||||
<script src="/static/frame_layout.js"></script>
|
<script src="/static/frame_layout.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ Each module in this package exposes:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from . import calendar, photos, whiteboard
|
from . import calendar, photos, tasks, whiteboard
|
||||||
|
|
||||||
WIDGET_TYPES = {
|
WIDGET_TYPES = {
|
||||||
"photos": photos,
|
"photos": photos,
|
||||||
"calendar": calendar,
|
"calendar": calendar,
|
||||||
"whiteboard": whiteboard,
|
"whiteboard": whiteboard,
|
||||||
|
"tasks": tasks,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from ..db import widget_locked
|
|||||||
from ..models import CalendarWidgetConfig, Frame, Widget
|
from ..models import CalendarWidgetConfig, Frame, Widget
|
||||||
from ..routers.common import (
|
from ..routers.common import (
|
||||||
get_or_refresh_calendar_events_for_widget,
|
get_or_refresh_calendar_events_for_widget,
|
||||||
get_or_refresh_tasks_for_widget,
|
|
||||||
get_or_refresh_weather_for_widget,
|
get_or_refresh_weather_for_widget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,16 +46,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
|||||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||||
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
||||||
tasks = (
|
|
||||||
get_or_refresh_tasks_for_widget(db, frame, widget)
|
|
||||||
if (cfg.view == "week" and cfg.tasks_enabled) else None
|
|
||||||
)
|
|
||||||
|
|
||||||
return _build(
|
return _build(
|
||||||
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
|
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
|
||||||
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
|
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
|
||||||
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
|
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
|
||||||
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
|
week_days=cfg.week_days, week_layout=cfg.week_layout,
|
||||||
week_start_offset=cfg.week_start_offset,
|
week_start_offset=cfg.week_start_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Tasks widget: a simple outstanding-task checklist in its own region --
|
||||||
|
split out of the calendar widget's old week-view-only task list (see
|
||||||
|
models.TaskWidgetConfig) so a task list can be placed and sized on its
|
||||||
|
own, independent of any calendar's view/footprint.
|
||||||
|
|
||||||
|
No "enabled" concept and no button actions: the widget's mere presence
|
||||||
|
on the grid is the on/off switch (same as every other widget type), and
|
||||||
|
its cache refreshes on the same throttled schedule as weather -- nothing
|
||||||
|
here to advance/back/force."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..calendar_render import _build_tasks
|
||||||
|
from ..models import Frame, TaskWidgetConfig, Widget
|
||||||
|
from ..routers.common import get_or_refresh_tasks_for_widget
|
||||||
|
from ._shared import placeholder_image
|
||||||
|
|
||||||
|
ACTION_LABELS: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||||
|
is_normal_wake: bool = True) -> Image.Image:
|
||||||
|
"""is_normal_wake is unused here -- see app/widgets/photos.py's
|
||||||
|
identical note; every widget type's render() shares one call
|
||||||
|
signature regardless of which ones actually care."""
|
||||||
|
cfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
|
if not cfg.calendar_key or not cfg.user_id:
|
||||||
|
return placeholder_image(target_w, target_h, ["Tasks widget", "not configured yet"])
|
||||||
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||||
|
return _build_tasks(tasks, target_w, target_h)
|
||||||
|
|
||||||
|
|
||||||
|
ACTIONS: dict = {}
|
||||||
@@ -20,6 +20,7 @@ from app.models import (
|
|||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
ServerSettings,
|
ServerSettings,
|
||||||
|
TaskWidgetConfig,
|
||||||
Widget,
|
Widget,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
)
|
)
|
||||||
@@ -179,7 +180,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",
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
|
||||||
"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"))
|
||||||
@@ -233,6 +234,92 @@ 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):
|
||||||
|
"""Exercises _migration_17's actual data-extraction SQL (the real
|
||||||
|
"existing widget-system database upgrading past this migration"
|
||||||
|
scenario): a calendar_widget_configs row in its pre-17 shape (tasks_*
|
||||||
|
columns still present, still holding a configured task source) should
|
||||||
|
come out the other side as a sibling `tasks` widget carrying that
|
||||||
|
source, with calendar_widget_configs no longer having tasks_*
|
||||||
|
columns at all."""
|
||||||
|
with db_module.engine.begin() as conn:
|
||||||
|
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||||
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE calendar_widget_configs ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
||||||
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"cached_events TEXT, "
|
||||||
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
||||||
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||||
|
"weather_cities TEXT, "
|
||||||
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"weather_cached TEXT, "
|
||||||
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
||||||
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
||||||
|
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||||
|
"tasks_calendar_key TEXT, "
|
||||||
|
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"tasks_cached TEXT)"
|
||||||
|
))
|
||||||
|
conn.execute(text("UPDATE schema_version SET version = 16"))
|
||||||
|
|
||||||
|
user = make_user(db_session, "task-owner")
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
# Frame #1's auto-migrated widget is a full-panel "photos" one --
|
||||||
|
# remove it to free up grid space for the calendar widget below (and
|
||||||
|
# for the tasks widget this migration is expected to carve out).
|
||||||
|
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
calendar_widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2,
|
||||||
|
sort_order=1, created_at=time.time())
|
||||||
|
db_session.add(calendar_widget)
|
||||||
|
db_session.flush()
|
||||||
|
calendar_widget_id = calendar_widget.id
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
with db_module.engine.begin() as conn:
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO calendar_widget_configs "
|
||||||
|
"(widget_id, tasks_enabled, tasks_user_id, tasks_calendar_key, tasks_checked_at, tasks_cached) "
|
||||||
|
"VALUES (:widget_id, 1, :user_id, 'caldav:/some/tasks/', 123.0, '[{\"summary\": \"Buy milk\"}]')"
|
||||||
|
), {"widget_id": calendar_widget_id, "user_id": user_id})
|
||||||
|
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
with db_module.engine.begin() as conn:
|
||||||
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
||||||
|
assert version == MIGRATIONS[-1][0]
|
||||||
|
|
||||||
|
columns = {c["name"] for c in inspect(db_module.engine).get_columns("calendar_widget_configs")}
|
||||||
|
assert not any(c.startswith("tasks_") for c in columns)
|
||||||
|
|
||||||
|
task_widgets = db_session.scalars(
|
||||||
|
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
|
||||||
|
).all()
|
||||||
|
assert len(task_widgets) == 1
|
||||||
|
task_widget = task_widgets[0]
|
||||||
|
|
||||||
|
cfg = db_session.get(TaskWidgetConfig, task_widget.id)
|
||||||
|
assert cfg.user_id == user_id
|
||||||
|
assert cfg.calendar_key == "caldav:/some/tasks/"
|
||||||
|
assert cfg.checked_at == 123.0
|
||||||
|
assert cfg.cached == [{"summary": "Buy milk"}]
|
||||||
|
|
||||||
|
# Auto-placed without overlapping the calendar widget it was split from.
|
||||||
|
cal = db_session.get(Widget, calendar_widget_id)
|
||||||
|
assert not grid.overlaps((cal.x, cal.y, cal.w, cal.h),
|
||||||
|
(task_widget.x, task_widget.y, task_widget.w, task_widget.h))
|
||||||
|
|
||||||
|
|
||||||
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
|
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
|
||||||
"""A frame whose mode was "calendar" (not "photos") gets a calendar
|
"""A frame whose mode was "calendar" (not "photos") gets a calendar
|
||||||
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
|
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
|
||||||
@@ -243,7 +330,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",
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
|
||||||
"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"))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models import (
|
|||||||
CalendarWidgetConfig,
|
CalendarWidgetConfig,
|
||||||
Frame,
|
Frame,
|
||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
|
TaskWidgetConfig,
|
||||||
User,
|
User,
|
||||||
Widget,
|
Widget,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
@@ -62,6 +63,16 @@ def _add_calendar_widget(db_session, frame: Frame) -> Widget:
|
|||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
def _add_tasks_widget(db_session, frame: Frame) -> Widget:
|
||||||
|
widget = Widget(frame_id=frame.id, 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
|
||||||
|
|
||||||
|
|
||||||
# --- whiteboard-source ---
|
# --- whiteboard-source ---
|
||||||
|
|
||||||
def test_whiteboard_source_owner_can_set_it(client, db_session):
|
def test_whiteboard_source_owner_can_set_it(client, db_session):
|
||||||
@@ -158,7 +169,7 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
|
|||||||
|
|
||||||
def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
||||||
frame = _setup_two_linked_users(client, db_session)
|
frame = _setup_two_linked_users(client, db_session)
|
||||||
widget = _add_calendar_widget(db_session, frame)
|
widget = _add_tasks_widget(db_session, frame)
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
login(client, "bob")
|
||||||
|
|
||||||
@@ -167,14 +178,14 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
bob_row = db_session.query(User).filter_by(username="bob").one()
|
bob_row = db_session.query(User).filter_by(username="bob").one()
|
||||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||||
assert cfg.tasks_user_id == bob_row.id
|
assert cfg.user_id == bob_row.id
|
||||||
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
|
assert cfg.calendar_key == "caldav:/some/tasks/"
|
||||||
|
|
||||||
|
|
||||||
def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||||
frame = _setup_two_linked_users(client, db_session)
|
frame = _setup_two_linked_users(client, db_session)
|
||||||
widget = _add_calendar_widget(db_session, frame)
|
widget = _add_tasks_widget(db_session, frame)
|
||||||
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
||||||
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
|
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
|
||||||
|
|
||||||
@@ -184,9 +195,9 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
|||||||
headers=csrf_headers(client))
|
headers=csrf_headers(client))
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||||
assert cfg.tasks_user_id is None
|
assert cfg.user_id is None
|
||||||
assert cfg.tasks_calendar_key is None
|
assert cfg.calendar_key is None
|
||||||
|
|
||||||
|
|
||||||
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
|
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
|
||||||
@@ -196,7 +207,7 @@ def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_sessio
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_tasks_source_400s_when_widget_is_not_a_calendar(client, db_session):
|
def test_tasks_source_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",
|
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from app.calendar_render import CALENDAR_VIEWS, render_calendar
|
from app.calendar_render import CALENDAR_VIEWS, render_calendar, render_tasks
|
||||||
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
|
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
|
||||||
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
|
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
|
||||||
|
|
||||||
@@ -55,11 +55,10 @@ def test_calendar_render_size_empty_events():
|
|||||||
assert len(data) == EXPECTED_BYTES
|
assert len(data) == EXPECTED_BYTES
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_render_size_with_fetch_summary_and_tasks():
|
def test_calendar_render_size_with_fetch_summary():
|
||||||
tasks = [{"summary": "Buy milk", "completed": False}, {"summary": "Walk the dog", "completed": True}]
|
|
||||||
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
||||||
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable",
|
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable",
|
||||||
week_days=5, week_layout="vertical", tasks=tasks)
|
week_days=5, week_layout="vertical")
|
||||||
assert len(data) == EXPECTED_BYTES
|
assert len(data) == EXPECTED_BYTES
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +68,25 @@ def test_calendar_render_size_with_week_start_offset():
|
|||||||
assert len(data) == EXPECTED_BYTES
|
assert len(data) == EXPECTED_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
# --- tasks widget (split out of the calendar widget's old week-view-only task list) ---
|
||||||
|
|
||||||
|
_SAMPLE_TASKS = [
|
||||||
|
{"summary": "Buy milk", "due": "2026-08-02"},
|
||||||
|
{"summary": "Walk the dog", "due": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
||||||
|
def test_tasks_render_size_across_orientations(orientation):
|
||||||
|
data = render_tasks(_SAMPLE_TASKS, orientation=orientation, palette_rgb=None)
|
||||||
|
assert len(data) == EXPECTED_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_render_size_empty():
|
||||||
|
data = render_tasks([], orientation="landscape", palette_rgb=None)
|
||||||
|
assert len(data) == EXPECTED_BYTES
|
||||||
|
|
||||||
|
|
||||||
# --- render_panel (the widget-system compositor) ---
|
# --- render_panel (the widget-system compositor) ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
|
|||||||
f"/api/frames/1/widgets/{widget.id}/config",
|
f"/api/frames/1/widgets/{widget.id}/config",
|
||||||
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
|
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
|
||||||
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
|
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
|
||||||
"calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"},
|
"calendar_weather_units": "celsius"},
|
||||||
headers=csrf_headers(client),
|
headers=csrf_headers(client),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
@@ -78,7 +78,6 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
|
|||||||
assert cfg.week_layout == "vertical"
|
assert cfg.week_layout == "vertical"
|
||||||
assert cfg.weather_enabled is True
|
assert cfg.weather_enabled is True
|
||||||
assert cfg.weather_units == "celsius"
|
assert cfg.weather_units == "celsius"
|
||||||
assert cfg.tasks_enabled 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):
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
|||||||
return frame, widget
|
return frame, widget
|
||||||
|
|
||||||
|
|
||||||
def _stub_fetches(monkeypatch, events=None, weather=None, tasks=None):
|
def _stub_fetches(monkeypatch, events=None, weather=None):
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
|
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
|
||||||
lambda db, frame, widget: (events or [], ""))
|
lambda db, frame, widget: (events or [], ""))
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
||||||
lambda db, frame, widget: weather or [])
|
lambda db, frame, widget: weather or [])
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
|
|
||||||
lambda db, frame, widget: tasks or [])
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_produces_a_correctly_sized_image(db_session, monkeypatch):
|
def test_render_produces_a_correctly_sized_image(db_session, monkeypatch):
|
||||||
@@ -93,27 +91,11 @@ def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
|
|||||||
lambda db, frame, widget: ([], ""))
|
lambda db, frame, widget: ([], ""))
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
||||||
lambda db, frame, widget: calls.append(1) or [])
|
lambda db, frame, widget: calls.append(1) or [])
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
|
|
||||||
lambda db, frame, widget: [])
|
|
||||||
|
|
||||||
widgets.calendar.render(db_session, frame, widget, 400, 300)
|
widgets.calendar.render(db_session, frame, widget, 400, 300)
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_tasks_only_fetched_for_week_view_when_enabled(db_session, monkeypatch):
|
|
||||||
frame, widget = _make_widget(db_session, view="agenda", tasks_enabled=True) # not week view
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
|
|
||||||
lambda db, frame, widget: ([], ""))
|
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
|
||||||
lambda db, frame, widget: [])
|
|
||||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
|
|
||||||
lambda db, frame, widget: calls.append(1) or [])
|
|
||||||
|
|
||||||
widgets.calendar.render(db_session, frame, widget, 400, 300)
|
|
||||||
assert calls == [] # agenda view -- tasks never shown, so never fetched
|
|
||||||
|
|
||||||
|
|
||||||
def test_advance_action_increments_browse_offset(db_session):
|
def test_advance_action_increments_browse_offset(db_session):
|
||||||
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
|
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
|
||||||
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
|
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""app.widgets.tasks -- unit-level, no HTTP: constructs Widget/
|
||||||
|
TaskWidgetConfig rows directly and monkeypatches the underlying fetch
|
||||||
|
call (get_or_refresh_tasks_for_widget, throttle/CalDAV-fetch logic
|
||||||
|
covered separately). Split out of the old calendar widget's week-view-
|
||||||
|
only task list -- see models.TaskWidgetConfig."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app import widgets
|
||||||
|
from app.models import Frame, TaskWidgetConfig, Widget
|
||||||
|
|
||||||
|
from .conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||||
|
frame = db_session.get(Frame, 1)
|
||||||
|
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
|
||||||
|
sort_order=0, created_at=time.time())
|
||||||
|
db_session.add(widget)
|
||||||
|
db_session.flush()
|
||||||
|
db_session.add(TaskWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||||
|
db_session.commit()
|
||||||
|
return frame, widget
|
||||||
|
|
||||||
|
|
||||||
|
def _make_configured_widget(db_session) -> tuple[Frame, Widget]:
|
||||||
|
user = make_user(db_session, "task-owner")
|
||||||
|
return _make_widget(db_session, user_id=user.id, calendar_key="caldav:/some/tasks/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session):
|
||||||
|
frame, widget = _make_widget(db_session) # no user_id/calendar_key set
|
||||||
|
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_configured_widget_produces_a_correctly_sized_image(db_session, monkeypatch):
|
||||||
|
frame, widget = _make_configured_widget(db_session)
|
||||||
|
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
|
||||||
|
lambda db, frame, widget: [{"summary": "Buy milk", "due": None}])
|
||||||
|
|
||||||
|
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
assert img.mode == "RGB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_configured_but_empty_task_list_still_renders(db_session, monkeypatch):
|
||||||
|
frame, widget = _make_configured_widget(db_session)
|
||||||
|
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||||
|
|
||||||
|
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||||
|
assert img.size == (300, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
|
||||||
|
"""grid.MIN_FOOTPRINT["tasks"] is (2, 2) cells -- on an 8x5 grid
|
||||||
|
against a full 800x480 panel that's a 200x192 box, the smallest a
|
||||||
|
tasks widget can actually be placed at."""
|
||||||
|
frame, widget = _make_configured_widget(db_session)
|
||||||
|
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||||
|
|
||||||
|
img = widgets.tasks.render(db_session, frame, widget, 200, 192)
|
||||||
|
assert img.size == (200, 192)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_button_actions():
|
||||||
|
"""A passive checklist on the same throttled-refresh cadence as
|
||||||
|
weather -- nothing to advance/back/force."""
|
||||||
|
assert widgets.tasks.ACTIONS == {}
|
||||||
|
assert widgets.tasks.ACTION_LABELS == {}
|
||||||
Reference in New Issue
Block a user