Let a tasks widget merge multiple task lists, checkbox+color like calendar
Tasks widgets could only ever point at one CalDAV task list (a radio- button picker, owner-only). Now they merge any number of included task lists across every linked user, same checkbox-inclusion + optional pinned-color shape a calendar widget already has for its calendars -- FrameTaskList mirrors FrameCalendar exactly, down to the same owner- adds/anyone-mutes permission split (api_widget_task_list_select/ api_widget_task_list_color). Reused calendar_render._event_colors/ _draw_color_bar as-is for the per-task color bar -- a task dict's owner_display_name/color_index is exactly that function's single- source fallback shape. Also added an opt-in "show tasks completed in the last 24 hours" toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks now accepts a completed_since cutoff and returns completed VTODOs (with their completion time) instead of silently dropping them, and _draw_tasks gives a completed task a filled checkbox + muted text instead of the normal empty-box/due-date row. Migration 18 splits the single-source TaskWidgetConfig columns (added by 17, splitting tasks out of the calendar widget in the first place) into frame_task_lists, carrying forward each widget's existing single source as its first included list -- same shape migration 9 used carrying forward frame_calendars' old single opt-in. Verified live in the browser (desktop + mobile): the new "Included task lists" + "Recently completed" dialog sections, the show_completed toggle actually persisting through a real HTTP round-trip, and no regression in the calendar widget's own "Included calendars" dialog. Full suite (192 tests, including new merge_tasks/config_save/migration coverage) passes.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
|
||||
plus every setting/action that used to assume a frame had at most one
|
||||
widget of a given type -- photo queue, calendar inclusion/color, tasks
|
||||
source, whiteboard source, and their preview endpoints. Split out of
|
||||
inclusion/color, whiteboard source, and their preview endpoints. Split out of
|
||||
api_frames.py (which keeps frame-wide settings: orientation, quiet
|
||||
hours, palette, firmware, stats) once a frame could hold more than one
|
||||
widget of the same type, at which point "the frame's calendar settings"
|
||||
@@ -34,6 +34,7 @@ from ..models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
@@ -51,6 +52,7 @@ from .common import (
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
task_sources_for_widget,
|
||||
valid_http_url,
|
||||
webdav_creds_for,
|
||||
)
|
||||
@@ -255,6 +257,8 @@ def api_widget_config_save(
|
||||
calendar_week_start_offset: int | None = Form(None),
|
||||
calendar_weather_enabled: bool | None = Form(None),
|
||||
calendar_weather_units: str | None = Form(None),
|
||||
# tasks
|
||||
tasks_show_completed: bool | None = Form(None),
|
||||
):
|
||||
"""Every field optional -- same partial-update, form-urlencoded
|
||||
convention as the old frame-level api_config_save, now scoped to one
|
||||
@@ -317,6 +321,11 @@ def api_widget_config_save(
|
||||
# new unit label.
|
||||
ccfg.weather_checked_at = 0.0
|
||||
ccfg.weather_units = calendar_weather_units
|
||||
elif widget.widget_type == "tasks":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
||||
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
||||
tcfg.show_completed = tasks_show_completed
|
||||
tcfg.checked_at = 0.0 # pick up the change promptly
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
@@ -618,58 +627,107 @@ def api_widget_preview_calendar(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Tasks: source/preview ------------------------------------------------
|
||||
# --- Tasks: inclusion/color/preview ---------------------------------------
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
|
||||
def api_widget_preview_tasks(
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""The same cached task list a live device render would use, same
|
||||
"reflects what's currently saved" convention as the other preview
|
||||
endpoints."""
|
||||
"""The same cached merged task list a live device render would use,
|
||||
same "reflects what's currently saved" convention as the other
|
||||
preview endpoints."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "tasks")
|
||||
tcfg = db.get(TaskWidgetConfig, widget.id)
|
||||
if not tcfg.calendar_key or not tcfg.user_id:
|
||||
raise HTTPException(400, "No task list configured on this widget yet")
|
||||
if not task_sources_for_widget(db, widget):
|
||||
raise HTTPException(400, "No task lists included on this widget yet")
|
||||
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class TasksSourceRequest(BaseModel):
|
||||
calendar_key: str | None # None clears the source
|
||||
class TaskListSelectRequest(BaseModel):
|
||||
user_id: int
|
||||
calendar_key: str
|
||||
calendar_label: str = ""
|
||||
included: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source")
|
||||
def api_widget_tasks_source(
|
||||
body: TasksSourceRequest, request: Request,
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select")
|
||||
def api_widget_task_list_select(
|
||||
body: TaskListSelectRequest, request: Request,
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Points this tasks widget at one of the calling user's own CalDAV
|
||||
calendars -- same owner-controls-their-own-data permission split as
|
||||
calendar-select's included=True, since this is volunteering personal
|
||||
calendar data, not a display setting a controller should get to pick
|
||||
on someone else's behalf. None clears the source; clearing (unlike
|
||||
setting) isn't ownership-gated -- like muting a shared calendar,
|
||||
anyone linked to the frame can turn off a task list they'd rather
|
||||
not see, but only its owner can point the widget at one of their
|
||||
calendars to begin with."""
|
||||
"""Include/exclude one CalDAV task list (calendar_key "caldav:<href>",
|
||||
see FrameTaskList) on this tasks widget -- same one-sided permission
|
||||
split as api_widget_calendar_select: turning a list ON requires being
|
||||
its owner (nobody can add someone else's task list to a shared frame
|
||||
for them), turning one OFF only requires being linked to the frame at
|
||||
all, so anyone sharing the display can mute a list they'd rather not
|
||||
see there even if they don't own it. Deliberately not
|
||||
require_widget_control for the same reason."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "tasks")
|
||||
user = require_user_api(request, db)
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
if body.calendar_key is None:
|
||||
cfg.user_id = None
|
||||
cfg.calendar_key = None
|
||||
cfg.cached = None
|
||||
else:
|
||||
cfg.user_id = user.id
|
||||
cfg.calendar_key = body.calendar_key
|
||||
cfg.checked_at = 0.0 # pick up the change promptly
|
||||
return {"status": "saved", "calendar_key": body.calendar_key}
|
||||
if body.included and body.user_id != user.id:
|
||||
raise HTTPException(403, "Only a task list's owner can add it to a frame")
|
||||
row = db.execute(
|
||||
select(FrameTaskList).where(
|
||||
FrameTaskList.widget_id == widget.id,
|
||||
FrameTaskList.user_id == body.user_id,
|
||||
FrameTaskList.calendar_key == body.calendar_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
if not body.included:
|
||||
raise HTTPException(404, "Not currently included on this widget")
|
||||
row = FrameTaskList(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
|
||||
db.add(row)
|
||||
row.included = body.included
|
||||
if body.calendar_label:
|
||||
row.calendar_label = body.calendar_label
|
||||
# Force this widget's merged cache to pick up the change promptly
|
||||
# rather than waiting out the throttle.
|
||||
db.get(TaskWidgetConfig, widget.id).checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "included": row.included}
|
||||
|
||||
|
||||
class TaskListColorRequest(BaseModel):
|
||||
calendar_key: str
|
||||
color_index: int | None # None clears the pin, reverting to auto-cycle
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-color")
|
||||
def api_widget_task_list_color(
|
||||
body: TaskListColorRequest, request: Request,
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Pins a specific panel color to one of your own included task
|
||||
lists (models.FrameTaskList.color_index) -- always owner-only, same
|
||||
as api_widget_calendar_color. None clears the pin, reverting
|
||||
calendar_render.py to its auto-cycle-by-owner-name behavior for this
|
||||
list."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "tasks")
|
||||
user = require_user_api(request, db)
|
||||
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
|
||||
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
|
||||
row = db.execute(
|
||||
select(FrameTaskList).where(
|
||||
FrameTaskList.widget_id == widget.id,
|
||||
FrameTaskList.user_id == user.id,
|
||||
FrameTaskList.calendar_key == body.calendar_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not included on this widget")
|
||||
row.color_index = body.color_index
|
||||
db.get(TaskWidgetConfig, widget.id).checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "color_index": row.color_index}
|
||||
|
||||
|
||||
class WeatherCityAddRequest(BaseModel):
|
||||
@@ -736,9 +794,9 @@ def api_widget_whiteboard_source(
|
||||
"""Points this widget at one of the calling user's own WebDAV (or
|
||||
reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
|
||||
same owner-controls-their-own-data permission split as
|
||||
api_widget_tasks_source: only the account owner can set the widget to
|
||||
use it, but anyone linked to the frame can clear it, same as muting a
|
||||
shared calendar."""
|
||||
api_widget_task_list_select: only the account owner can set the
|
||||
widget to use it, but anyone linked to the frame can clear it, same
|
||||
as muting a shared calendar."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "whiteboard")
|
||||
user = require_user_api(request, db)
|
||||
|
||||
Reference in New Issue
Block a user