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
|
||||
|
||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||
placed/sized widgets (photos/calendar/whiteboard), like arranging icons on
|
||||
an Android home screen. A frame can hold several widgets of the same type
|
||||
(e.g. two photo widgets pointed at different Immich albums side by side).
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks), like arranging
|
||||
icons on an Android home screen. A frame can hold several widgets of the
|
||||
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
|
||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
||||
@@ -19,17 +20,21 @@ a button press does.
|
||||
## Data model
|
||||
|
||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"`), `x`/`y`/`w`/`h` (grid
|
||||
cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"`), `x`/`y`/`w`/`h`
|
||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||
already checked) -- that's what keeps compositing simple: no z-order,
|
||||
no blending, just N independent regions pasted onto one shared canvas.
|
||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, each keyed by
|
||||
`widget_id` with `ondelete="CASCADE"` -- rather than one wide table with
|
||||
every type's mostly-irrelevant columns. `PhotoWidgetConfig` mirrors
|
||||
`app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||
each keyed by `widget_id` with `ondelete="CASCADE"` -- rather than one
|
||||
wide table with every type's mostly-irrelevant columns. `PhotoWidgetConfig`
|
||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
advance/back/queue logic ports across widget instances unchanged.
|
||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||
`CalendarWidgetConfig` (a week-view-only task list); split into its own
|
||||
widget type (migration 17) so a task list can be placed and sized
|
||||
independent of any calendar's view/footprint.
|
||||
- `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame
|
||||
can now have more than one independent calendar widget, each with its
|
||||
own set of included calendars.
|
||||
@@ -50,16 +55,16 @@ orientation change rather than trying to remap coordinates.
|
||||
|
||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||
size-tier scaling), whiteboard 2x2. Enforced both client-side (UX, in the
|
||||
Layout tab's drag/resize canvas -- `static/frame_layout.js`) and
|
||||
server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2. Enforced both client-side
|
||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
alone.
|
||||
|
||||
## Rendering: one shared compositor
|
||||
|
||||
`app/widgets/` is the render/action registry -- one module per
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`), each
|
||||
exposing:
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`),
|
||||
each exposing:
|
||||
|
||||
- `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
|
||||
@@ -69,7 +74,8 @@ exposing:
|
||||
bad moment doesn't blank the whole panel.
|
||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||
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-
|
||||
assignment UI.
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Renders calendar frame mode's three views (agenda/week/month) 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.
|
||||
"""Renders calendar frame mode's three views (agenda/week/month), and the
|
||||
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
|
||||
list used to be a calendar-widget-only week-view slot, split out into
|
||||
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"
|
||||
(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],
|
||||
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
|
||||
margin: int = MARGIN) -> None:
|
||||
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont) -> None:
|
||||
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
|
||||
glyph + due date (if any) + summary per outstanding task, same
|
||||
header/rule/row-cap/truncation shape as _draw_agenda_day's event
|
||||
list so the "week view, one slot replaced by tasks instead of a day"
|
||||
layout (see _build_week) reads as one consistent design rather than
|
||||
two different widgets bolted together. Reuses _draw_mixed_line so a
|
||||
task summary with emoji in it renders the same way an event
|
||||
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."""
|
||||
list so the standalone tasks widget (see _build_tasks) reads as the
|
||||
same consistent design as everything else on-panel, not a
|
||||
bolted-together look. Reuses _draw_mixed_line so a task summary with
|
||||
emoji in it renders the same way an event title's does."""
|
||||
x0, y0, w, h = region
|
||||
text_x0, text_y0 = x0 + margin, y0 + margin
|
||||
text_w = w - margin * 2
|
||||
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
|
||||
text_w = w - MARGIN * 2
|
||||
draw_text(img, (text_x0, text_y0), "Tasks", title_font)
|
||||
y = text_y0 + title_font.size + 12
|
||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
||||
y += 12
|
||||
|
||||
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:
|
||||
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,
|
||||
week_start: int, palette_rgb: list | None = None,
|
||||
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:
|
||||
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
||||
columns (layout="horizontal", the original fixed-at-7 behavior
|
||||
generalized) or stacked bands (layout="vertical", reusing
|
||||
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
||||
an arbitrary day count instead of a hardcoded 2). `tasks` (see
|
||||
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.
|
||||
an arbitrary day count instead of a hardcoded 2).
|
||||
|
||||
At the default 7 days, the view anchors to week_start (a fixed
|
||||
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)
|
||||
else:
|
||||
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] = []
|
||||
|
||||
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))
|
||||
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
|
||||
section_h = target_h // days
|
||||
for i in range(day_count):
|
||||
for i in range(days):
|
||||
section_y0 = i * section_h
|
||||
if i > 0:
|
||||
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),
|
||||
title_font, body_font, owners_seen, palette_rgb,
|
||||
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
|
||||
|
||||
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
|
||||
header_h = 44
|
||||
|
||||
for col in range(day_count):
|
||||
for col in range(days):
|
||||
day = week_first_day + timedelta(days=col)
|
||||
x0 = MARGIN + col * col_w
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
||||
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:
|
||||
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||
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)
|
||||
elif effective_view == "week":
|
||||
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":
|
||||
# Never given weather or tasks -- no room for either at typical
|
||||
# month-cell size, same reasoning that already keeps this view
|
||||
# to density dots instead of literal event text (see
|
||||
# _build_month's own docstring). Colors are still passed
|
||||
# through, though -- that's a different concern (legibility of
|
||||
# individual events) than needing a whole extra strip/slot of
|
||||
# content.
|
||||
# Never given weather -- no room for it at typical month-cell
|
||||
# size, same reasoning that already keeps this view to density
|
||||
# dots instead of literal event text (see _build_month's own
|
||||
# docstring). Colors are still passed through, though -- that's
|
||||
# a different concern (legibility of individual events) than
|
||||
# needing a whole extra strip of content.
|
||||
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
|
||||
else:
|
||||
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,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
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
|
||||
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
|
||||
invariant every other renderer honors. weather_cities is
|
||||
routers/common.py's get_or_refresh_weather() cache, or None/[] to
|
||||
omit the weather strip entirely (also always omitted for view ==
|
||||
"month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
|
||||
the task list entirely -- only ever drawn for view == "week", see
|
||||
_build_week."""
|
||||
"month")."""
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
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)
|
||||
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,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
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
|
||||
PNG in logical (upright) orientation -- mirrors
|
||||
image_pipeline.render_preview_png's relationship to render_frame."""
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
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)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
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]]:
|
||||
"""Same lock/refresh/commit dance as frame_locked, additionally
|
||||
resolving and refreshing the widget's own per-type config row
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see
|
||||
models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame*
|
||||
granularity -- the exact same per-frame threading.Lock frame_locked
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
|
||||
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
|
||||
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
|
||||
of multi-lock deadlock bugs, and this project's actual concurrency
|
||||
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
|
||||
# widget crammed into 1x1 would be illegible regardless of size-tier
|
||||
# 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]] = {
|
||||
"photos": (1, 1),
|
||||
"calendar": (3, 2),
|
||||
"whiteboard": (2, 2),
|
||||
"tasks": (2, 2),
|
||||
}
|
||||
|
||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||
|
||||
+158
-5
@@ -27,6 +27,7 @@ from .models import (
|
||||
FrameButtonAction,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
TaskWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
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 = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -350,6 +455,7 @@ MIGRATIONS = [
|
||||
(14, _migration_14),
|
||||
(15, _migration_15),
|
||||
(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_layout=frame.calendar_week_layout,
|
||||
week_start_offset=frame.calendar_week_start_offset,
|
||||
tasks_enabled=frame.calendar_tasks_enabled,
|
||||
tasks_user_id=frame.calendar_tasks_user_id,
|
||||
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,
|
||||
# tasks_* deliberately not carried over -- see _task_config_from_frame,
|
||||
# a sibling standalone widget now, not part of this config.
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
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(_photo_config_from_frame(frame, photo_widget.id))
|
||||
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
|
||||
|
||||
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":
|
||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||
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:
|
||||
|
||||
+37
-11
@@ -407,7 +407,7 @@ class Widget(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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)
|
||||
y: 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,
|
||||
minus calendar_photo_inlay (dropped: arbitrary widget placement
|
||||
subsumes what a fixed 50/50 inlay split did, so it's not a special
|
||||
case anymore, just place a photo widget alongside). "Included
|
||||
calendars" is its own table (FrameCalendar), widget_id-keyed so each
|
||||
calendar widget on a frame has its own independent set."""
|
||||
case anymore, just place a photo widget alongside) and minus
|
||||
tasks_* (also dropped: split out into its own standalone widget
|
||||
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"
|
||||
|
||||
@@ -471,13 +474,35 @@ class CalendarWidgetConfig(Base):
|
||||
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
||||
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
|
||||
)
|
||||
tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
|
||||
class TaskWidgetConfig(Base):
|
||||
"""One tasks widget's settings + cached-fetch state -- split out of
|
||||
CalendarWidgetConfig (which used to carry these as tasks_* columns,
|
||||
a week-view-only task list bolted onto a calendar widget) so a task
|
||||
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):
|
||||
@@ -500,6 +525,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
"photos": PhotoWidgetConfig,
|
||||
"calendar": CalendarWidgetConfig,
|
||||
"whiteboard": WhiteboardWidgetConfig,
|
||||
"tasks": TaskWidgetConfig,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Everything scoped to one specific widget rather than "the frame":
|
||||
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
|
||||
plus every setting/action that used to assume a frame had at most one
|
||||
widget of a given type -- photo queue, calendar inclusion/color/tasks,
|
||||
whiteboard source, and their preview endpoints. Split out of
|
||||
widget of a given type -- photo queue, calendar inclusion/color, tasks
|
||||
source, whiteboard source, and their preview endpoints. Split out of
|
||||
api_frames.py (which keeps frame-wide settings: orientation, quiet
|
||||
hours, palette, firmware, stats) once a frame could hold more than one
|
||||
widget of the same type, at which point "the frame's calendar settings"
|
||||
@@ -35,6 +35,7 @@ from ..models import (
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
PhotoWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
WIDGET_CONFIG_MODELS,
|
||||
Widget,
|
||||
@@ -254,7 +255,6 @@ def api_widget_config_save(
|
||||
calendar_week_start_offset: int | None = Form(None),
|
||||
calendar_weather_enabled: bool | None = Form(None),
|
||||
calendar_weather_units: str | None = Form(None),
|
||||
calendar_tasks_enabled: bool | None = Form(None),
|
||||
):
|
||||
"""Every field optional -- same partial-update, form-urlencoded
|
||||
convention as the old frame-level api_config_save, now scoped to one
|
||||
@@ -317,8 +317,6 @@ def api_widget_config_save(
|
||||
# new unit label.
|
||||
ccfg.weather_checked_at = 0.0
|
||||
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:
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
@@ -506,7 +504,7 @@ def api_widget_preview_rendered(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Calendar: inclusion/color/tasks/weather/preview --------------------
|
||||
# --- Calendar: inclusion/color/weather/preview ---------------------------
|
||||
|
||||
class CalendarSelectRequest(BaseModel):
|
||||
user_id: int
|
||||
@@ -609,21 +607,37 @@ def api_widget_preview_calendar(
|
||||
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
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
|
||||
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(
|
||||
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
||||
week_start=ccfg.week_start,
|
||||
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,
|
||||
)
|
||||
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):
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Points this widget's week-view task list at one of the calling
|
||||
user's own CalDAV calendars -- same owner-controls-their-own-data
|
||||
permission split as calendar-select's included=True, since this is
|
||||
volunteering personal calendar data, not a display setting a
|
||||
controller should get to pick on someone else's behalf. None clears
|
||||
the source; clearing (unlike setting) isn't ownership-gated -- like
|
||||
muting a shared calendar, anyone linked to the frame can turn off a
|
||||
task list they'd rather not see, but only its owner can point the
|
||||
widget at one of their calendars to begin with."""
|
||||
"""Points this tasks widget at one of the calling user's own CalDAV
|
||||
calendars -- same owner-controls-their-own-data permission split as
|
||||
calendar-select's included=True, since this is volunteering personal
|
||||
calendar data, not a display setting a controller should get to pick
|
||||
on someone else's behalf. None clears the source; clearing (unlike
|
||||
setting) isn't ownership-gated -- like muting a shared calendar,
|
||||
anyone linked to the frame can turn off a task list they'd rather
|
||||
not see, but only its owner can point the widget at one of their
|
||||
calendars to begin with."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "calendar")
|
||||
_require_widget_type(widget, "tasks")
|
||||
user = require_user_api(request, db)
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
if body.calendar_key is None:
|
||||
cfg.tasks_user_id = None
|
||||
cfg.tasks_calendar_key = None
|
||||
cfg.tasks_cached = None
|
||||
cfg.user_id = None
|
||||
cfg.calendar_key = None
|
||||
cfg.cached = None
|
||||
else:
|
||||
cfg.tasks_user_id = user.id
|
||||
cfg.tasks_calendar_key = body.calendar_key
|
||||
cfg.tasks_checked_at = 0.0 # pick up the change promptly
|
||||
cfg.user_id = user.id
|
||||
cfg.calendar_key = body.calendar_key
|
||||
cfg.checked_at = 0.0 # pick up the change promptly
|
||||
return {"status": "saved", "calendar_key": body.calendar_key}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..models import (
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
PhotoWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
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]:
|
||||
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
|
||||
cadence as event merging), reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py). [] if tasks are off, no source is set, or
|
||||
the source user's CalDAV credentials/calendar_key have gone missing
|
||||
(e.g. they unlinked their account). A refetch failure keeps the
|
||||
last-known list rather than going blank for one bad cycle, same
|
||||
reasoning as get_or_refresh_weather_for_widget."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
|
||||
cadence as event merging), reading/writing TaskWidgetConfig (see
|
||||
app/widgets/tasks.py). [] if no source is set, or the source user's
|
||||
CalDAV credentials/calendar_key have gone missing (e.g. they
|
||||
unlinked their account). A refetch failure keeps the last-known list
|
||||
rather than going blank for one bad cycle, same reasoning as
|
||||
get_or_refresh_weather_for_widget."""
|
||||
cfg = db.get(TaskWidgetConfig, widget.id)
|
||||
if not cfg.calendar_key or not cfg.user_id:
|
||||
return []
|
||||
now = time.time()
|
||||
if cfg.tasks_cached is not None and now - cfg.tasks_checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return cfg.tasks_cached
|
||||
if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return cfg.cached
|
||||
|
||||
user = db.get(User, cfg.tasks_user_id)
|
||||
key = cfg.tasks_calendar_key
|
||||
user = db.get(User, cfg.user_id)
|
||||
key = cfg.calendar_key
|
||||
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
|
||||
return cfg.tasks_cached or []
|
||||
return cfg.cached or []
|
||||
href = key[len("caldav:"):]
|
||||
try:
|
||||
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
|
||||
except caldav_client.CalDavError as e:
|
||||
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
|
||||
return cfg.tasks_cached or []
|
||||
return cfg.cached or []
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.tasks_cached = tasks
|
||||
locked_cfg.tasks_checked_at = now
|
||||
locked_cfg.cached = tasks
|
||||
locked_cfg.checked_at = now
|
||||
return tasks
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from ..models import (
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
PhotoWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
UserFrame,
|
||||
WhiteboardWidgetConfig,
|
||||
@@ -136,22 +137,22 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe
|
||||
return result
|
||||
|
||||
|
||||
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
|
||||
"""Whose CalDAV calendar this widget's week-view task list currently
|
||||
pulls from, and its label -- for showing "using <name>'s Chores
|
||||
list" to everyone linked, not just whoever set it. None if no
|
||||
source is configured."""
|
||||
if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
|
||||
def _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None:
|
||||
"""Whose CalDAV calendar this tasks widget currently pulls from, and
|
||||
its label -- for showing "using <name>'s Chores list" to everyone
|
||||
linked, not just whoever set it. None if no source is configured."""
|
||||
if not task_cfg.user_id or not task_cfg.calendar_key:
|
||||
return None
|
||||
user = db.get(User, calendar_cfg.tasks_user_id)
|
||||
user = db.get(User, task_cfg.user_id)
|
||||
if user is None:
|
||||
return None
|
||||
label = calendar_cfg.tasks_calendar_key
|
||||
label = task_cfg.calendar_key
|
||||
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
|
||||
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:
|
||||
@@ -195,7 +196,6 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
||||
|
||||
if widget.widget_type == "calendar":
|
||||
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", {
|
||||
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
|
||||
"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,
|
||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||
"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,
|
||||
"tasks_source": _tasks_source_info(db, calendar_cfg),
|
||||
"tasks_source": _tasks_source_info(db, task_cfg),
|
||||
})
|
||||
|
||||
if widget.widget_type == "whiteboard":
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
// Shared display names for widget_type, everywhere one shows up in the
|
||||
// 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) {
|
||||
// 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
|
||||
// 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
|
||||
// frame_layout.html, since which one runs depends on which widget's gear
|
||||
// icon was clicked).
|
||||
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog };
|
||||
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog };
|
||||
const DIALOG_INIT = {
|
||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||
tasks: initTasksDialog,
|
||||
};
|
||||
const DIALOG_CLOSE = {
|
||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||
tasks: closeTasksDialog,
|
||||
};
|
||||
|
||||
let openDialogWidgetType = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
// it into the shared <dialog>, points window.FRAME_API at this specific
|
||||
// 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() {
|
||||
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);
|
||||
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>
|
||||
</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;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<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/widget_dialog_calendar.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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,10 +36,11 @@ Each module in this package exposes:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import calendar, photos, whiteboard
|
||||
from . import calendar, photos, tasks, whiteboard
|
||||
|
||||
WIDGET_TYPES = {
|
||||
"photos": photos,
|
||||
"calendar": calendar,
|
||||
"whiteboard": whiteboard,
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ from ..db import widget_locked
|
||||
from ..models import CalendarWidgetConfig, Frame, Widget
|
||||
from ..routers.common import (
|
||||
get_or_refresh_calendar_events_for_widget,
|
||||
get_or_refresh_tasks_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)
|
||||
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
|
||||
tasks = (
|
||||
get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||
if (cfg.view == "week" and cfg.tasks_enabled) else None
|
||||
)
|
||||
|
||||
return _build(
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
TaskWidgetConfig,
|
||||
Widget,
|
||||
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
|
||||
migrate."""
|
||||
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"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
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"]
|
||||
|
||||
|
||||
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):
|
||||
"""A frame whose mode was "calendar" (not "photos") gets a calendar
|
||||
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
|
||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs",
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
|
||||
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WhiteboardWidgetConfig,
|
||||
@@ -62,6 +63,16 @@ def _add_calendar_widget(db_session, frame: Frame) -> 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 ---
|
||||
|
||||
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):
|
||||
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()
|
||||
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
|
||||
|
||||
bob_row = db_session.query(User).filter_by(username="bob").one()
|
||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
||||
assert cfg.tasks_user_id == bob_row.id
|
||||
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
|
||||
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||
assert cfg.user_id == bob_row.id
|
||||
assert cfg.calendar_key == "caldav:/some/tasks/"
|
||||
|
||||
|
||||
def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_calendar_widget(db_session, frame)
|
||||
widget = _add_tasks_widget(db_session, frame)
|
||||
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
||||
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
|
||||
|
||||
@@ -184,9 +195,9 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
||||
assert cfg.tasks_user_id is None
|
||||
assert cfg.tasks_calendar_key is None
|
||||
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
||||
assert cfg.user_id is None
|
||||
assert cfg.calendar_key is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
photo_widget_id = _photo_widget_id(db_session, frame)
|
||||
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
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.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
|
||||
|
||||
|
||||
def test_calendar_render_size_with_fetch_summary_and_tasks():
|
||||
tasks = [{"summary": "Buy milk", "completed": False}, {"summary": "Walk the dog", "completed": True}]
|
||||
def test_calendar_render_size_with_fetch_summary():
|
||||
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
||||
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
|
||||
|
||||
|
||||
@@ -69,6 +68,25 @@ def test_calendar_render_size_with_week_start_offset():
|
||||
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) ---
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
|
||||
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
|
||||
"calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"},
|
||||
"calendar_weather_units": "celsius"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
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.weather_enabled is True
|
||||
assert cfg.weather_units == "celsius"
|
||||
assert cfg.tasks_enabled is True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
lambda db, frame, widget: (events or [], ""))
|
||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
||||
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):
|
||||
@@ -93,27 +91,11 @@ def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
|
||||
lambda db, frame, widget: ([], ""))
|
||||
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
|
||||
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)
|
||||
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):
|
||||
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
|
||||
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