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.
187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
"""api_widgets.py's config-save (form-urlencoded, dispatched by
|
|
widget_type) and the photo-queue endpoints it shares the file with --
|
|
the moved-and-consolidated counterparts of the old frame-level
|
|
api_config_save/api_queue/etc. No prior HTTP-level coverage existed for
|
|
either the old or new shape of these endpoints; added after manual
|
|
browser testing caught api_widget_config_save expecting a JSON body
|
|
while the (unmodified, copied-over) dialog JS posts form-urlencoded data
|
|
-- a real bug an HTTP-level test would have caught immediately."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget
|
|
|
|
from .conftest import csrf_headers
|
|
|
|
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
|
|
|
|
|
|
def _photo_widget(db_session) -> Widget:
|
|
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one()
|
|
|
|
|
|
def _add_calendar_widget(db_session) -> Widget:
|
|
import time
|
|
|
|
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(widget)
|
|
db_session.flush()
|
|
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
|
|
db_session.commit()
|
|
return widget
|
|
|
|
|
|
def _add_tasks_widget(db_session) -> Widget:
|
|
import time
|
|
|
|
widget = Widget(frame_id=1, widget_type="tasks", x=0, y=0, w=2, h=2,
|
|
sort_order=1, created_at=time.time())
|
|
db_session.add(widget)
|
|
db_session.flush()
|
|
db_session.add(TaskWidgetConfig(widget_id=widget.id))
|
|
db_session.commit()
|
|
return widget
|
|
|
|
|
|
def _mock_immich(monkeypatch):
|
|
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
|
|
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
|
|
|
|
|
|
# --- api_widget_config_save ---
|
|
|
|
def test_config_save_updates_a_photos_widget(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _photo_widget(db_session)
|
|
|
|
resp = client.post(
|
|
f"/api/frames/1/widgets/{widget.id}/config",
|
|
data={"album_id": "album-42", "order": "shuffle", "display_mode": "stretch_fill",
|
|
"queue_target_len": "15"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
|
assert cfg.album_id == "album-42"
|
|
assert cfg.order == "shuffle"
|
|
assert cfg.display_mode == "stretch_fill"
|
|
assert cfg.queue_target_len == 15
|
|
|
|
|
|
def test_config_save_updates_a_calendar_widget(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _add_calendar_widget(db_session)
|
|
|
|
resp = client.post(
|
|
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"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
|
assert cfg.view == "week"
|
|
assert cfg.week_start == 1
|
|
assert cfg.week_days == 5
|
|
assert cfg.week_layout == "vertical"
|
|
assert cfg.weather_enabled is True
|
|
assert cfg.weather_units == "celsius"
|
|
|
|
|
|
def test_config_save_updates_a_tasks_widget(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _add_tasks_widget(db_session)
|
|
|
|
resp = client.post(
|
|
f"/api/frames/1/widgets/{widget.id}/config",
|
|
data={"tasks_show_completed": "true"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
cfg = db_session.get(TaskWidgetConfig, widget.id)
|
|
assert cfg.show_completed is True
|
|
|
|
|
|
def test_config_save_only_partially_updates_provided_fields(client, db_session):
|
|
"""Fields not present in the POST are left untouched -- the whole
|
|
point of the partial-update convention (each dialog's own form only
|
|
ever posts its own fields)."""
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _photo_widget(db_session)
|
|
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
|
cfg.order = "shuffle"
|
|
db_session.commit()
|
|
|
|
resp = client.post(
|
|
f"/api/frames/1/widgets/{widget.id}/config",
|
|
data={"queue_target_len": "10"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
|
assert cfg.queue_target_len == 10
|
|
assert cfg.order == "shuffle" # untouched
|
|
|
|
|
|
def test_config_save_calendar_fields_are_a_no_op_on_a_photos_widget(client, db_session):
|
|
"""Posting calendar-shaped fields at a photos widget's config
|
|
endpoint doesn't error -- it just doesn't apply, since the dispatch
|
|
is purely by widget.widget_type."""
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _photo_widget(db_session)
|
|
|
|
resp = client.post(
|
|
f"/api/frames/1/widgets/{widget.id}/config",
|
|
data={"calendar_view": "week"},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
def test_config_save_404s_for_a_widget_id_that_does_not_exist(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
resp = client.post("/api/frames/1/widgets/999999/config", data={"order": "shuffle"},
|
|
headers=csrf_headers(client))
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# --- photo queue (moved from the old frame-level /api/frames/{id}/queue) ---
|
|
|
|
def test_queue_requires_a_configured_album(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _photo_widget(db_session)
|
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_queue_returns_current_and_upcoming(client, db_session, monkeypatch):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
frame = db_session.get(Frame, 1)
|
|
frame.immich_url = "http://immich.example.com"
|
|
frame.immich_api_key = "key"
|
|
widget = _photo_widget(db_session)
|
|
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
|
cfg.album_id = "album-1"
|
|
db_session.commit()
|
|
_mock_immich(monkeypatch)
|
|
|
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert data["current"]["id"] == "asset-1"
|
|
assert [u["id"] for u in data["upcoming"]] == ["asset-2", "asset-3"]
|
|
assert data["control"]["you"] is True
|
|
|
|
|
|
def test_queue_400s_when_widget_is_not_photos(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _add_calendar_widget(db_session)
|
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
|
|
assert resp.status_code == 400
|