From 37bd657299c6eb17aa26e91e6bb2f6af63c83500 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Fri, 24 Jul 2026 09:26:28 -0400 Subject: [PATCH] Widget system Phase 2: full cutover to widget-based rendering device.py's mode-keyed dispatch is replaced by a real compositor: load a frame's widgets, compute pixel rects via app/grid.py, render each through its widget module, and composite with render_panel. Physical NEXT/BACK buttons now execute each frame's assigned FrameButtonAction rows instead of one hardcoded per-mode action. api_frames.py, manage.py, and common.py's build_manage_content are repointed to read/write the frame's widget config rows instead of the old Frame columns, and every settings page (Photos/Calendar/ Whiteboard tabs) now pre-fills its form from the same widget config the write endpoints actually save to -- previously the read and write sides would have silently diverged. The old mode selector and photo-inlay checkbox are removed along with their now-inert wiring; arbitrary widget placement subsumes what the fixed inlay split did. Ships together with Phase 1 (per-type render/action modules) since splitting the read/write cutover across deploys would have left settings changes with no visible effect. --- server/app/routers/api_frames.py | 390 ++++++++++-------- server/app/routers/common.py | 376 +++++++---------- server/app/routers/device.py | 346 ++++++---------- server/app/routers/frame_pages.py | 51 ++- server/app/routers/manage.py | 43 +- server/app/static/frame_calendar.js | 9 +- server/app/static/frame_header.js | 36 +- server/app/static/theme.css | 5 - server/app/templates/_frame_mode_picker.html | 4 - server/app/templates/_frame_mode_select.html | 5 - server/app/templates/_frame_tabs.html | 4 +- server/app/templates/app_base.html | 1 - server/app/templates/frame_calendar.html | 42 +- server/app/templates/frame_config.html | 1 - server/app/templates/frame_photos.html | 11 +- server/app/templates/frame_stats.html | 1 - server/app/templates/frame_whiteboard.html | 8 +- server/app/widgets/__init__.py | 9 +- server/app/widgets/calendar.py | 14 +- server/app/widgets/photos.py | 8 +- server/app/widgets/whiteboard.py | 6 +- server/tests/test_device_widget_dispatch.py | 177 ++++++++ server/tests/test_manage_widget_dispatch.py | 121 ++++++ server/tests/test_permission_boundaries.py | 103 ++++- .../test_whiteboard_refresh_and_browse.py | 65 +-- server/tests/test_widgets_calendar.py | 25 ++ 26 files changed, 1070 insertions(+), 791 deletions(-) delete mode 100644 server/app/templates/_frame_mode_picker.html delete mode 100644 server/app/templates/_frame_mode_select.html create mode 100644 server/tests/test_device_widget_dispatch.py create mode 100644 server/tests/test_manage_widget_dispatch.py diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index fddf590..eb1e3e3 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -27,7 +27,7 @@ from sqlalchemy.orm import Session from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client from ..auth import require_frame_control, require_frame_view, require_user_api -from ..db import frame_locked, get_db +from ..db import frame_locked, get_db, widget_locked from ..image_pipeline import ( DEFAULT_DISPLAY_MODE, DISPLAY_MODES, @@ -36,23 +36,23 @@ from ..image_pipeline import ( render_preview_png, ) from ..firmware import firmware_path, parse_app_version -from ..models import BatteryLog, Frame, FrameCalendar +from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig from .common import ( - FRAME_MODES, OVERDUE_FACTOR, battery_estimate_s, calendar_sources_for_frame, fetch_source_and_faces, - get_or_refresh_calendar_events, - get_or_refresh_tasks, - get_or_refresh_weather, - get_or_refresh_whiteboard, + get_or_refresh_calendar_events_for_widget, + get_or_refresh_tasks_for_widget, + get_or_refresh_weather_for_widget, + get_or_refresh_whiteboard_for_widget, immich_client_for, immich_creds, list_assets, - require_configured, + photo_widget_config_or_404, valid_http_url, webdav_creds_for, + widget_of_type, ) logger = logging.getLogger(__name__) @@ -100,9 +100,7 @@ def api_config_save( color_boost: float | None = Form(None), contrast_boost: float | None = Form(None), dither_strength: float | None = Form(None), - mode: str | None = Form(None), calendar_view: str | None = Form(None), - calendar_photo_inlay: bool | None = Form(None), calendar_week_start: int | None = Form(None), calendar_week_days: int | None = Form(None), calendar_week_layout: str | None = Form(None), @@ -113,29 +111,32 @@ def api_config_save( frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): + """Partial update, split across up to three sequential lock spans -- + frame-level settings, the frame's photo widget, the frame's calendar + widget -- rather than one, now that those settings live on separate + rows (see models.Widget's per-type extension tables). Never nested + (see db.widget_locked's own docstring on why that would deadlock). + + `mode` and `calendar_photo_inlay` are no longer accepted here: mode + no longer governs anything (a frame's widgets do), and photo inlay + has no widget-system equivalent (place an independent photo widget + alongside instead -- see CalendarWidgetConfig's docstring). Both are + harmless no-ops if an old cached page still POSTs them -- FastAPI + silently ignores form fields with no matching parameter. + + Until the widget-placement UI (a later phase) lets a frame have more + than one widget of a type, "the photo widget" / "the calendar + widget" below unambiguously means the frame's single auto-migrated + one (see widget_of_type) -- these fields are silent no-ops if the + frame doesn't have one yet, same posture as any other partial update + whose target doesn't exist.""" with frame_locked(db, frame.id) as cfg: if name is not None: cfg.name = name.strip()[:64] or cfg.name - if album_id is not None and album_id != cfg.album_id: - # A newly selected album starts clean -- the old current photo - # and queue don't mean anything in the new album's context. - cfg.current_asset_id = "" - cfg.current_asset_set_at = 0.0 - cfg.queue = [] - cfg.queue_cursor = 0 - cfg.history = [] - cfg.excluded_asset_ids = [] - cfg.album_id = album_id - if order is not None: - cfg.order = order if order in ("sequential", "shuffle") else "sequential" if refresh_interval_s is not None: cfg.refresh_interval_s = max( MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s) ) - if display_mode is not None: - cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE - if queue_target_len is not None: - cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) if orientation is not None: cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape" if quiet_hours_enabled is not None: @@ -173,46 +174,74 @@ def api_config_save( cfg.contrast_boost = max(0.0, min(2.0, contrast_boost)) if dither_strength is not None: cfg.dither_strength = max(0.0, min(1.0, dither_strength)) - if mode is not None: - cfg.mode = mode if mode in FRAME_MODES else "photos" - if calendar_view is not None: - new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" - if new_view != cfg.calendar_view: - # A stale offset means something different in a different - # view's units (days vs. weeks vs. months) -- same - # reasoning as album_id's reset above. - cfg.calendar_browse_offset = 0 - cfg.calendar_view = new_view - if calendar_photo_inlay is not None: - cfg.calendar_photo_inlay = calendar_photo_inlay - if calendar_week_start is not None: - cfg.calendar_week_start = max(0, min(6, calendar_week_start)) - if calendar_week_days is not None: - new_days = max(2, min(10, calendar_week_days)) - if new_days != cfg.calendar_week_days: - # A stale offset counts a different-sized page under the - # old day count -- same reasoning as calendar_view's own - # reset below. - cfg.calendar_browse_offset = 0 - cfg.calendar_week_days = new_days - if calendar_week_layout is not None: - cfg.calendar_week_layout = calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" - if calendar_week_start_offset is not None: - new_offset = max(-30, min(30, calendar_week_start_offset)) - if new_offset != cfg.calendar_week_start_offset: - cfg.calendar_browse_offset = 0 - cfg.calendar_week_start_offset = new_offset - if calendar_weather_enabled is not None: - cfg.calendar_weather_enabled = calendar_weather_enabled - if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): - if calendar_weather_units != cfg.calendar_weather_units: - # Cached forecasts are in the old unit -- force a refetch - # rather than showing stale numbers under a new unit label. - cfg.calendar_weather_checked_at = 0.0 - cfg.calendar_weather_units = calendar_weather_units - if calendar_tasks_enabled is not None: - cfg.calendar_tasks_enabled = calendar_tasks_enabled cfg.stats_config_saves += 1 + + photo_widget = widget_of_type(db, frame, "photos") + photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len)) + if photo_widget and photo_fields_present: + with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg): + if album_id is not None and album_id != pcfg.album_id: + # A newly selected album starts clean -- the old current + # photo and queue don't mean anything in the new album's + # context. + pcfg.current_asset_id = "" + pcfg.current_asset_set_at = 0.0 + pcfg.queue = [] + pcfg.queue_cursor = 0 + pcfg.history = [] + pcfg.excluded_asset_ids = [] + pcfg.album_id = album_id + if order is not None: + pcfg.order = order if order in ("sequential", "shuffle") else "sequential" + if display_mode is not None: + pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE + if queue_target_len is not None: + pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) + + calendar_widget = widget_of_type(db, frame, "calendar") + calendar_fields_present = any(v is not None for v in ( + calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout, + calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled, + )) + if calendar_widget and calendar_fields_present: + with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg): + if calendar_view is not None: + new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" + if new_view != ccfg.view: + # A stale offset means something different in a + # different view's units (days vs. weeks vs. months). + ccfg.browse_offset = 0 + ccfg.view = new_view + if calendar_week_start is not None: + ccfg.week_start = max(0, min(6, calendar_week_start)) + if calendar_week_days is not None: + new_days = max(2, min(10, calendar_week_days)) + if new_days != ccfg.week_days: + # A stale offset counts a different-sized page under + # the old day count. + ccfg.browse_offset = 0 + ccfg.week_days = new_days + if calendar_week_layout is not None: + ccfg.week_layout = ( + calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" + ) + if calendar_week_start_offset is not None: + new_offset = max(-30, min(30, calendar_week_start_offset)) + if new_offset != ccfg.week_start_offset: + ccfg.browse_offset = 0 + ccfg.week_start_offset = new_offset + if calendar_weather_enabled is not None: + ccfg.weather_enabled = calendar_weather_enabled + if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): + if calendar_weather_units != ccfg.weather_units: + # Cached forecasts are in the old unit -- force a + # refetch rather than showing stale numbers under a + # 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 + return {"status": "saved"} @@ -250,28 +279,29 @@ def api_queue( request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): user = require_user_api(request, db) - require_configured(frame) + photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) + assets = list_assets(client, pcfg.album_id) - with frame_locked(db, frame.id) as cfg: - photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) - photo_queue.sync_queue_length(cfg, assets) + with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.get_current(locked_pcfg, assets, locked_frame, + in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) + photo_queue.sync_queue_length(locked_pcfg, assets) snapshot = { - "current_asset_id": cfg.current_asset_id, - "queue": list(cfg.queue), - "last_seen": cfg.last_seen, - "overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR, - "firmware_version": cfg.device_firmware_version, - "firmware_available": cfg.firmware_available_version, - "battery_percent": cfg.battery_percent, - "battery_as_of": cfg.battery_as_of, - "battery_estimate_s": battery_estimate_s(cfg, db), - "controller_id": cfg.controlled_by_user_id, + "current_asset_id": locked_pcfg.current_asset_id, + "queue": list(locked_pcfg.queue), + "last_seen": locked_frame.last_seen, + "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR, + "firmware_version": locked_frame.device_firmware_version, + "firmware_available": locked_frame.firmware_available_version, + "battery_percent": locked_frame.battery_percent, + "battery_as_of": locked_frame.battery_as_of, + "battery_estimate_s": battery_estimate_s(locked_frame, db), + "controller_id": locked_frame.controlled_by_user_id, "controller": ( - (cfg.controlled_by.display_name or cfg.controlled_by.username) - if cfg.controlled_by + (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) + if locked_frame.controlled_by else None ), } @@ -330,7 +360,10 @@ def api_queue_reorder( the client sent that's no longer actually queued is dropped, and any ID the server has that the client didn't know about is appended rather than lost.""" - with frame_locked(db, frame.id) as cfg: + photo_widget = widget_of_type(db, frame, "photos") + if photo_widget is None: + raise HTTPException(404, "No photo widget on this frame") + with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg): current_set = set(cfg.queue) reordered = [asset_id for asset_id in body.queue if asset_id in current_set] reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)] @@ -351,7 +384,10 @@ def api_queue_promote( """Moves a single photo to the front of the queue -- "Show next". Unlike reorder, doesn't depend on the client knowing the queue's exact current order, so it can't fail from staleness.""" - with frame_locked(db, frame.id) as cfg: + photo_widget = widget_of_type(db, frame, "photos") + if photo_widget is None: + raise HTTPException(404, "No photo widget on this frame") + with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg): if body.asset_id not in cfg.queue: raise HTTPException(400, "That photo is no longer in the upcoming queue") cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id] @@ -370,24 +406,26 @@ def api_queue_remove( ): """Permanently removes a photo from this frame's rotation. Does NOT touch Immich or the album itself; see photo_queue.remove_from_rotation().""" - require_configured(frame) + photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) + assets = list_assets(client, pcfg.album_id) - with frame_locked(db, frame.id) as cfg: - photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg) + with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame) return {"status": "removed"} @router.get("/api/frames/{frame_id}/thumbnail/{asset_id}") -def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)): +def api_thumbnail( + asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) +): """Scoped to what this frame is actually showing/queuing -- a user merely linked to view this frame shouldn't be able to pull thumbnails for arbitrary asset ids in the owner's Immich library, only the frame's own curated album. Same rule device.frame_share and manage.manage_thumbnail already enforce.""" - require_configured(frame) - if asset_id != frame.current_asset_id and asset_id not in frame.queue: + _, pcfg = photo_widget_config_or_404(db, frame) + if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue: raise HTTPException(404, "Not on this frame") client = immich_client_for(frame) try: @@ -397,19 +435,22 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)): return Response(content=content, media_type=content_type) -def _current_asset_id(frame: Frame, db: Session) -> str: +def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]: """Same idempotent get_current() dance /api/frames/{id}/queue uses -- picks a current photo if none is set yet, otherwise just reads it, - never advances early.""" - require_configured(frame) + never advances early. Returns the photo widget's own config + alongside the asset id, since callers (api_preview_rendered) also + need its display_mode.""" + photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - with frame_locked(db, frame.id) as cfg: - photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) - asset_id = cfg.current_asset_id + assets = list_assets(client, pcfg.album_id) + with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.get_current(locked_pcfg, assets, locked_frame, + in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) + asset_id = locked_pcfg.current_asset_id if not asset_id: raise HTTPException(404, "No current photo") - return asset_id + return asset_id, pcfg @router.get("/api/frames/{frame_id}/preview/original") @@ -417,7 +458,7 @@ def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session """The Immich preview image behind the currently-displayed photo, unprocessed -- the "now displaying" side of the Configuration tab's before/after comparison.""" - asset_id = _current_asset_id(frame, db) + asset_id, _ = _current_asset_id(frame, db) client = immich_client_for(frame) try: jpeg_bytes = client.download_asset_preview(asset_id) @@ -432,13 +473,15 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session pipeline (display mode, palette, color/contrast/dithering) and exported as a PNG -- the "how it will look on the frame" side of the comparison. Not a live preview of unsaved slider values; reflects - whatever's currently saved.""" - asset_id = _current_asset_id(frame, db) + whatever's currently saved. display_mode comes from the photo + widget's own config now (palette/color/contrast/dither stay + frame-level -- one physical panel, one set of those).""" + asset_id, pcfg = _current_asset_id(frame, db) client = immich_client_for(frame) - source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id) + source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id) png = render_preview_png( source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, - display_mode=frame.display_mode, color_boost=frame.color_boost, + display_mode=pcfg.display_mode, color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, ) return Response(content=png, media_type="image/png") @@ -486,9 +529,11 @@ def api_calendar_select( row.included = body.included if body.calendar_label: row.calendar_label = body.calendar_label - # Force this frame's merged cache to pick up the change promptly - # rather than waiting out the throttle. - frame.calendar_checked_at = 0.0 + # Force the frame's calendar widget's merged cache to pick up the + # change promptly rather than waiting out the throttle. + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is not None: + db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0 db.commit() return {"status": "saved", "included": row.included} @@ -527,59 +572,40 @@ def api_calendar_color( if row is None: raise HTTPException(404, "Not included on this frame") row.color_index = body.color_index - frame.calendar_checked_at = 0.0 + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is not None: + db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0 db.commit() return {"status": "saved", "color_index": row.color_index} -def _calendar_photo_inlay(frame: Frame, db: Session): - """The photo-inlay's source image (any view now, not just agenda), or - None if inlay is off or the frame's photos-mode album isn't - configured. Shared shape between the live render (routers/device.py's - _render_calendar_mode) and this preview endpoint; small enough that - duplicating rather than factoring out is fine, since the two call - sites differ slightly in error handling.""" - if not frame.calendar_photo_inlay: - return None - url, key = immich_creds(frame) - if not (url and key and frame.album_id): - return None - try: - client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - with frame_locked(db, frame.id) as locked: - photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked)) - asset_id = locked.current_asset_id - if not asset_id: - return None - import io - - from PIL import Image - - return Image.open(io.BytesIO(client.download_asset_preview(asset_id))) - except HTTPException: - return None - - @router.get("/api/frames/{frame_id}/preview/calendar") def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): """The same merged, cached event set a live device render would use -- not a live preview of an unsaved calendar_view choice, same - "reflects what's currently saved" convention as preview/rendered.""" + "reflects what's currently saved" convention as preview/rendered. + No photo_inlay parameter anymore -- that's not a widget-system + concept (see CalendarWidgetConfig's docstring); place an independent + photo widget alongside instead.""" + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is None: + raise HTTPException(400, "No calendar widget on this frame yet") if not calendar_sources_for_frame(db, frame): raise HTTPException(400, "No calendars included on this frame yet") - events, summary = get_or_refresh_calendar_events(db, frame) - photo_inlay = _calendar_photo_inlay(frame, db) - view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" - weather_cities = get_or_refresh_weather(db, frame) - tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None + ccfg = db.get(CalendarWidgetConfig, calendar_widget.id) + events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget) + weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None + tasks = ( + get_or_refresh_tasks_for_widget(db, frame, calendar_widget) + if (ccfg.view == "week" and ccfg.tasks_enabled) else None + ) png = calendar_render.render_calendar_preview_png( - events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary, - week_start=frame.calendar_week_start, - weather_cities=weather_cities, weather_units=frame.calendar_weather_units, - week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks, - week_start_offset=frame.calendar_week_start_offset, + events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, + palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=None, 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_start_offset=ccfg.week_start_offset, ) return Response(content=png, media_type="image/png") @@ -606,15 +632,18 @@ def api_tasks_source( its owner can point the frame at one of their calendars to begin with.""" user = require_user_api(request, db) - with frame_locked(db, frame.id) as cfg: + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is None: + raise HTTPException(404, "No calendar widget on this frame yet") + with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): if body.calendar_key is None: - cfg.calendar_tasks_user_id = None - cfg.calendar_tasks_calendar_key = None - cfg.calendar_tasks_cached = None + cfg.tasks_user_id = None + cfg.tasks_calendar_key = None + cfg.tasks_cached = None else: - cfg.calendar_tasks_user_id = user.id - cfg.calendar_tasks_calendar_key = body.calendar_key - cfg.calendar_tasks_checked_at = 0.0 # pick up the change promptly + cfg.tasks_user_id = user.id + cfg.tasks_calendar_key = body.calendar_key + cfg.tasks_checked_at = 0.0 # pick up the change promptly return {"status": "saved", "calendar_key": body.calendar_key} @@ -636,18 +665,21 @@ def api_whiteboard_source( it, but anyone linked to the frame can clear it, same as muting a shared calendar.""" user = require_user_api(request, db) - with frame_locked(db, frame.id) as cfg: + whiteboard_widget = widget_of_type(db, frame, "whiteboard") + if whiteboard_widget is None: + raise HTTPException(404, "No whiteboard widget on this frame yet") + with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg): if body.url is None: - cfg.whiteboard_user_id = None - cfg.whiteboard_url = "" - cfg.whiteboard_cached_image = None + cfg.user_id = None + cfg.url = "" + cfg.cached_image = None else: stripped = body.url.strip() if not valid_http_url(stripped): raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL") - cfg.whiteboard_user_id = user.id - cfg.whiteboard_url = stripped - cfg.whiteboard_checked_at = 0.0 # pick up the change promptly + cfg.user_id = user.id + cfg.url = stripped + cfg.checked_at = 0.0 # pick up the change promptly return {"status": "saved", "url": body.url} @@ -695,9 +727,13 @@ def api_preview_whiteboard( Excalidraw export, same convention as preview/rendered and preview/calendar. force=True (the "Refresh now" button, as opposed to just reopening this tab) bypasses the fetch throttle.""" - png_bytes = get_or_refresh_whiteboard(db, frame, force=force) + whiteboard_widget = widget_of_type(db, frame, "whiteboard") + if whiteboard_widget is None: + raise HTTPException(400, "No whiteboard widget on this frame yet") + wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id) + png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force) if png_bytes is None: - if not frame.whiteboard_url: + if not wcfg.url: raise HTTPException(400, "No whiteboard configured on this frame yet") raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") import io @@ -726,17 +762,20 @@ def api_weather_city_add( to this frame's weather strip -- a frame-wide display setting (like calendar_view), not personal data, so this is gated the same way as api_config_save rather than the calendar-select owner/mute split.""" + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is None: + raise HTTPException(404, "No calendar widget on this frame yet") try: city = weather.geocode_city(body.name) except weather.WeatherFetchError as e: raise HTTPException(400, str(e)) from e - with frame_locked(db, frame.id) as cfg: - cities = list(cfg.calendar_weather_cities or []) + with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): + cities = list(cfg.weather_cities or []) if any(c["label"] == city["label"] for c in cities): raise HTTPException(400, f"{city['label']} is already on this frame's list") cities.append(city) - cfg.calendar_weather_cities = cities - cfg.calendar_weather_checked_at = 0.0 # pick up the new city promptly + cfg.weather_cities = cities + cfg.weather_checked_at = 0.0 # pick up the new city promptly return {"status": "saved", "city": city} @@ -750,11 +789,14 @@ def api_weather_city_remove( frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): - with frame_locked(db, frame.id) as cfg: - cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label] - cfg.calendar_weather_cities = cities - cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label] - cfg.calendar_weather_cached = cached + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is None: + raise HTTPException(404, "No calendar widget on this frame yet") + with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): + cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label] + cfg.weather_cities = cities + cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label] + cfg.weather_cached = cached return {"status": "saved"} diff --git a/server/app/routers/common.py b/server/app/routers/common.py index a1c9370..e06b4e2 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -16,11 +16,21 @@ from PIL import Image from sqlalchemy import select from sqlalchemy.orm import Session -from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard -from ..db import frame_locked, widget_locked -from ..image_pipeline import render_frame +from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard +from ..db import widget_locked +from ..image_pipeline import logical_render_size from ..immich_client import ImmichClient -from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, User, Widget, WhiteboardWidgetConfig +from ..models import ( + BatteryLog, + CalendarWidgetConfig, + Frame, + FrameButtonAction, + FrameCalendar, + PhotoWidgetConfig, + User, + Widget, + WhiteboardWidgetConfig, +) logger = logging.getLogger(__name__) @@ -73,14 +83,6 @@ def immich_client_for(frame: Frame) -> ImmichClient: return ImmichClient(url, key) -def require_configured(frame: Frame) -> None: - url, key = immich_creds(frame) - if not url or not key: - raise HTTPException(400, "Immich URL/API key not configured yet") - if not frame.album_id: - raise HTTPException(400, "No album configured yet") - - def list_assets(client: ImmichClient, album_id: str) -> list[dict]: try: assets = client.list_album_assets(album_id) @@ -118,14 +120,6 @@ def fetch_source_and_faces( return Image.open(io.BytesIO(jpeg_bytes)), faces -def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes: - source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id) - return render_frame(source, faces=faces, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, display_mode=frame.display_mode, - color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, - dither_strength=frame.dither_strength, manage=manage) - - def _avg_wake_interval_s(frame: Frame) -> float: """Average wall-clock seconds between wakes: refresh_interval_s scaled up for however much of each day quiet hours removes from the @@ -355,30 +349,63 @@ def _format_taken_at(exif: dict) -> str | None: return None -def _manage_content_asset_id(frame: Frame) -> str | None: - """Whether frame.current_asset_id refers to a photo actually visible - right now, for whichever mode is active -- always true in photos - mode; only true in calendar mode when that view's photo inlay is on - (otherwise current_asset_id could be stale, left over from whenever - photos mode last ran, and showing its location/date/share info on a - manage overlay over a view with no visible photo at all would be - actively misleading, not just unhelpful).""" - relevant = frame.mode != "calendar" or frame.calendar_photo_inlay - return frame.current_asset_id if relevant and frame.current_asset_id else None +def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None: + """The frame's first widget of this type, by placement order. Until + the placement UI (a later phase) ships, every frame has at most one + widget per type -- the auto-migrated default -- so callers needing + "the photo widget" / "the calendar widget" / "the whiteboard widget" + for what's still effectively a single-widget-per-type frame use this + rather than querying Widget directly. None if the frame has no widget + of this type.""" + return db.scalars( + select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type) + .order_by(Widget.sort_order) + ).first() -def _manage_content_region(frame: Frame) -> tuple[int, int, int, int] | None: - """Where the photo behind _manage_content_asset_id actually landed in - the logical canvas -- None (the whole canvas) in photos mode, or - calendar_render.inlay_region(...) when a calendar view's photo inlay - is what's showing. Needed so face labels (and, if ever added, other - photo-relative overlay positioning) land on the actual inlaid photo - instead of where a full-panel photo would have been.""" - if frame.mode == "calendar" and frame.calendar_photo_inlay: - from ..calendar_render import inlay_region +def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]: + """The frame's photo widget + its config, or a 400 if Immich creds or + an album aren't set up yet. Immich creds are frame/owner-level, but + album_id lives on PhotoWidgetConfig. Shared by api_frames.py and + manage.py, whose photo-related endpoints both need exactly this.""" + url, key = immich_creds(frame) + if not url or not key: + raise HTTPException(400, "Immich URL/API key not configured yet") + widget = widget_of_type(db, frame, "photos") + cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None + if widget is None or not cfg.album_id: + raise HTTPException(400, "No album configured yet") + return widget, cfg - return inlay_region(frame.orientation) - return None + +def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]: + return db.scalars( + select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos") + .order_by(Widget.sort_order) + ).all() + + +def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None: + """The one photo widget the manage overlay's location/date/share-link + boxes show info for -- unlike face labels (which generalize to every + photo widget on screen, see build_manage_content), there's only one + of each of these fixed panel corners to go around, so with more than + one photo widget some single one has to be picked. Resolution rule: + whichever photo widget the NEXT button's first assigned action + targets, falling back to the first photo widget by placement order + if none is button-assigned.""" + if not photo_widgets: + return None + next_actions = db.scalars( + select(FrameButtonAction) + .where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next") + .order_by(FrameButtonAction.sort_order) + ).all() + photo_widget_ids = {w.id for w in photo_widgets} + for action in next_actions: + if action.widget_id in photo_widget_ids: + return next(w for w in photo_widgets if w.id == action.widget_id) + return photo_widgets[0] def build_manage_content(db: Session, frame: Frame, request) -> dict: @@ -387,47 +414,66 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict: /frame/face-labels, both removed -- see the module docstring in manage_overlay.py) are now just internal calls made here, once, server-side, since compositing itself also moved server-side. - management_url and battery_percent always apply; location/date/ - share-URL/face-labels only when there's a real current photo (see - _manage_content_asset_id) -- absent otherwise, which - manage_overlay.compose() already treats as "skip that region", - exactly the graceful-degradation behavior the old firmware-fetched - version had.""" + management_url and battery_percent always apply. location/date/ + share-URL come from one "primary" photo widget (see + _primary_photo_widget -- there's only one of each of those fixed + panel corners, so with more than one photo widget on screen some + single one has to be picked); face labels generalize more simply, + since manage_overlay.compose() already takes a flat list and draws + each one independently -- every photo widget's own named faces get + concatenated in, each positioned within that widget's own region + (see face_labels.compute_face_labels' region param) rather than as + if a photo filled the whole panel.""" base = str(request.base_url).rstrip("/") content: dict = { "management_url": f"{base}/m/{frame.manage_token}", "battery_percent": frame.battery_percent, } - asset_id = _manage_content_asset_id(frame) - if not asset_id: + photo_widgets = photo_widgets_for_frame(db, frame) + if not photo_widgets: return content - client = immich_client_for(frame) - try: - asset = client.get_asset(asset_id) - faces = client.get_asset_faces(asset_id) - except httpx.HTTPError as e: - logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e) - return content - - exif = asset.get("exifInfo") or {} - content["location_lines"] = _format_location(exif) - content["taken_at"] = _format_taken_at(exif) - content["share_url"] = f"{base}/frame/share/{asset_id}" - - if any((face.get("person") or {}).get("name") for face in faces): + primary = _primary_photo_widget(db, frame, photo_widgets) + primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None + if primary_cfg and primary_cfg.current_asset_id: + client = immich_client_for(frame) try: - preview_bytes = client.download_asset_preview(asset_id) - from ..face_labels import compute_face_labels - - content["face_labels"] = compute_face_labels( - preview_bytes, faces, frame.display_mode, frame.orientation, - region=_manage_content_region(frame), - ) + asset = client.get_asset(primary_cfg.current_asset_id) except httpx.HTTPError as e: - logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e) + logger.warning( + "Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e + ) + else: + exif = asset.get("exifInfo") or {} + content["location_lines"] = _format_location(exif) + content["taken_at"] = _format_taken_at(exif) + content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}" + panel_w, panel_h = logical_render_size(frame.orientation) + face_labels: list[dict] = [] + for widget in photo_widgets: + cfg = db.get(PhotoWidgetConfig, widget.id) + if not cfg.current_asset_id: + continue + client = immich_client_for(frame) + try: + preview_bytes = client.download_asset_preview(cfg.current_asset_id) + faces = client.get_asset_faces(cfg.current_asset_id) + except httpx.HTTPError as e: + logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e) + continue + if not any((face.get("person") or {}).get("name") for face in faces): + continue # no Immich-identified person on this widget's current photo -- nothing to label + + from ..face_labels import compute_face_labels + + region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)) + face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation, + region=region)) + + if face_labels: + content["face_labels"] = face_labels return content @@ -459,45 +505,21 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed. return sources -def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]: +def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]: """Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S) -- same shape as the Gitea release-check throttle in api_frames.py's - api_firmware_check. One shared cache for the whole merged result - (every included user's events together), not per-user -- ICS feeds - are small and this refetches at most every ~20 minutes regardless of - how many are included, so per-user cache columns would add - bookkeeping for a marginal benefit.""" - now = time.time() - if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S: - return frame.calendar_cached_events, frame.calendar_fetch_summary - - sources = calendar_sources_for_frame(db, frame) - today = quiet_hours.local_date(frame) - events, summary = calendar_feed.merge_events( - sources, - today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS), - today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS), - ) - with frame_locked(db, frame.id) as locked: - locked.calendar_cached_events = events - locked.calendar_fetch_summary = summary - locked.calendar_checked_at = now - return events, summary - - -def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]: - """Widget-scoped twin of get_or_refresh_calendar_events above, - reading/writing CalendarWidgetConfig instead of Frame columns - directly -- same throttle/caching rationale, unchanged. Not yet used - by any router (see app/widgets/calendar.py, which this backs) -- - device.py's actual dispatch still calls the Frame-scoped version - above until the widget-system cutover lands; both exist side by side - until then. calendar_sources_for_frame stays frame_id-scoped (see - FrameCalendar's own docstring) until a later phase re-keys it to - widget_id, so every calendar widget on a frame currently shares the - same "included calendars" set -- not a real limitation yet since - nothing supports more than one calendar widget per frame end to end - until that phase lands.""" + api_firmware_check -- reading/writing CalendarWidgetConfig (see + app/widgets/calendar.py, which this backs). One shared cache for the + whole merged result (every included user's events together), not + per-user -- ICS feeds are small and this refetches at most every ~20 + minutes regardless of how many are included, so per-user cache + columns would add bookkeeping for a marginal benefit. + calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's + own docstring) until a later phase re-keys it to widget_id, so every + calendar widget on a frame currently shares the same "included + calendars" set -- not a real limitation yet since nothing supports + more than one calendar widget per frame end to end until that phase + lands.""" cfg = db.get(CalendarWidgetConfig, widget.id) now = time.time() if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S: @@ -517,44 +539,15 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: return events, summary -def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]: - """Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S, - much longer than calendar_feed's -- weather doesn't need to be - that fresh). [] if weather's off or no cities are configured. A city - whose refetch fails keeps its last-known days rather than going - blank for one bad cycle -- calendar_render.py would otherwise show a - real city as having no forecast at all just because one refresh hit - a network hiccup.""" - if not frame.calendar_weather_enabled or not frame.calendar_weather_cities: - return [] - now = time.time() - if (frame.calendar_weather_cached is not None - and now - frame.calendar_weather_checked_at < weather.CHECK_INTERVAL_S): - return frame.calendar_weather_cached - - previous_days = {c["label"]: c.get("days", {}) for c in (frame.calendar_weather_cached or [])} - result = [] - for city in frame.calendar_weather_cities: - try: - days = weather.fetch_daily_forecast( - city["latitude"], city["longitude"], frame.calendar_weather_units - ) - except weather.WeatherFetchError as e: - logger.warning("Could not refresh weather for %s: %s", city["label"], e) - days = previous_days.get(city["label"], {}) - result.append({"label": city["label"], "days": days}) - - with frame_locked(db, frame.id) as locked: - locked.calendar_weather_cached = result - locked.calendar_weather_checked_at = now - return result - - def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]: - """Widget-scoped twin of get_or_refresh_weather above -- same - throttle/caching rationale, unchanged. Not yet used by any router - (see app/widgets/calendar.py) -- both versions coexist until the - widget-system cutover lands.""" + """Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much + longer than calendar_feed's -- weather doesn't need to be that + fresh), reading/writing CalendarWidgetConfig (see + app/widgets/calendar.py). [] if weather's off or no cities are + configured. A city whose refetch fails keeps its last-known days + rather than going blank for one bad cycle -- calendar_render.py + would otherwise show a real city as having no forecast at all just + because one refresh hit a network hiccup.""" cfg = db.get(CalendarWidgetConfig, widget.id) if not cfg.weather_enabled or not cfg.weather_cities: return [] @@ -578,42 +571,14 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) return result -def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]: - """Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, - same cadence as event merging) -- [] 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.""" - if not frame.calendar_tasks_enabled or not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id: - return [] - now = time.time() - if (frame.calendar_tasks_cached is not None - and now - frame.calendar_tasks_checked_at < calendar_feed.CHECK_INTERVAL_S): - return frame.calendar_tasks_cached - - user = db.get(User, frame.calendar_tasks_user_id) - key = frame.calendar_tasks_calendar_key - if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"): - return frame.calendar_tasks_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 frame %d: %s", frame.id, e) - return frame.calendar_tasks_cached or [] - - with frame_locked(db, frame.id) as locked: - locked.calendar_tasks_cached = tasks - locked.calendar_tasks_checked_at = now - return tasks - - def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]: - """Widget-scoped twin of get_or_refresh_tasks above -- same throttle/ - caching rationale, unchanged. Not yet used by any router (see - app/widgets/calendar.py) -- both versions coexist until the - widget-system cutover lands.""" + """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: return [] @@ -653,48 +618,21 @@ def webdav_creds_for(user: User) -> tuple[str, str] | None: return None -def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) -> bytes | None: - """Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S) - -- None if no whiteboard source is configured, credentials are - missing (e.g. the owning user unlinked their WebDAV/CalDAV account), - or the most recent fetch/render failed and nothing was ever cached - yet. A failure after a previous success keeps showing the last - good render rather than going blank for one bad refresh cycle, same - reasoning as get_or_refresh_weather/get_or_refresh_tasks. force=True - (the web UI's "Refresh now" button) skips the throttle entirely -- - unlike a device's normal wake, a person clicking a button means do - it right now, not eventually once the cache goes stale.""" - if not frame.whiteboard_url or not frame.whiteboard_user_id: - return None - now = time.time() - if (not force and frame.whiteboard_cached_image is not None - and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S): - return frame.whiteboard_cached_image - - user = db.get(User, frame.whiteboard_user_id) - creds = webdav_creds_for(user) if user else None - if creds is None: - return frame.whiteboard_cached_image - - try: - png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1]) - except whiteboard.WhiteboardRenderError as e: - logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e) - return frame.whiteboard_cached_image - - with frame_locked(db, frame.id) as locked: - locked.whiteboard_cached_image = png - locked.whiteboard_checked_at = now - return png - - def get_or_refresh_whiteboard_for_widget( db: Session, frame: Frame, widget: Widget, force: bool = False ) -> bytes | None: - """Widget-scoped twin of get_or_refresh_whiteboard above -- same - throttle/caching/force rationale, unchanged. Not yet used by any - router (see app/widgets/whiteboard.py) -- both versions coexist - until the widget-system cutover lands.""" + """Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/ + writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) -- + None if no whiteboard source is configured, credentials are missing + (e.g. the owning user unlinked their WebDAV/CalDAV account), or the + most recent fetch/render failed and nothing was ever cached yet. A + failure after a previous success keeps showing the last good render + rather than going blank for one bad refresh cycle, same reasoning as + get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget. + force=True (the web UI's "Refresh now" button) skips the throttle + entirely -- unlike a device's normal wake, a person clicking a + button means do it right now, not eventually once the cache goes + stale.""" cfg = db.get(WhiteboardWidgetConfig, widget.id) if not cfg.url or not cfg.user_id: return None diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 13c773e..73bd160 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -12,39 +12,32 @@ see manage_overlay.py and common.build_manage_content).""" from __future__ import annotations -import io import logging import time import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse, RedirectResponse, Response -from PIL import Image from pydantic import BaseModel from sqlalchemy import delete, func, select from sqlalchemy.orm import Session -from .. import calendar_render, mail, photo_queue, quiet_hours +from .. import grid, mail, quiet_hours from ..auth import get_server_settings, require_device from ..db import frame_locked, get_db from ..firmware import firmware_path -from ..image_pipeline import render_frame, render_placeholder -from ..models import BatteryLog, Frame +from ..image_pipeline import logical_render_size, render_panel, render_placeholder +from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget +from ..widgets import WIDGET_TYPES from .common import ( BATTERY_HISTORY_MAX, BATTERY_LOG_MAX, RECHARGE_JUMP_PCT, RECHARGE_LOOKBACK, build_manage_content, - get_or_refresh_calendar_events, - get_or_refresh_tasks, - get_or_refresh_weather, - get_or_refresh_whiteboard, immich_client_for, immich_creds, - list_assets, - render_asset, - require_configured, + photo_widgets_for_frame, ) logger = logging.getLogger(__name__) @@ -53,11 +46,11 @@ router = APIRouter() def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes: - """What an unclaimed or not-yet-configured frame displays instead of a - photo -- instructions with a QR, rendered at 200 so the device treats - it as a perfectly normal image and never error-loops. The URLs are - built from the request's own base URL: whatever address the device - reached us at is by definition an address that works on this + """What an unclaimed or widget-less frame displays instead of real + content -- instructions with a QR, rendered at 200 so the device + treats it as a perfectly normal image and never error-loops. The + URLs are built from the request's own base URL: whatever address the + device reached us at is by definition an address that works on this network.""" base = str(request.base_url).rstrip("/") if frame.owner_user_id is None and frame.device_id: @@ -77,7 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non manage=manage, ) return render_placeholder( - ["Almost there!", "Pick an album for this frame:", base], + ["Almost there!", "Add a widget for this frame at", base], qr_url=base, orientation=frame.orientation, palette_rgb=frame.palette_rgb, @@ -85,187 +78,90 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non ) -def _frame_configured(frame: Frame) -> bool: - url, key = immich_creds(frame) - return bool(url and key and frame.album_id) +def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool) -> bytes: + """The widget-system compositor: renders every widget on this frame + into its own region (see app/grid.py for grid-cell -> pixel math) and + hands the results to image_pipeline.render_panel for the single + shared paste/enhance/overlay/quantize/pack pass. Replaces the old + per-mode RENDERERS dict -- a frame can now show several widgets at + once instead of exactly one mode owning the whole panel.""" + all_widgets = db.scalars( + select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) + ).all() + panel_w, panel_h = logical_render_size(frame.orientation) + regions = [] + for widget in all_widgets: + module = WIDGET_TYPES.get(widget.widget_type) + if module is None: + continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500 + px, py, pw, ph = grid.cell_to_pixels( + frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h) + ) + img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake) + regions.append(((px, py, pw, ph), img)) + return render_panel( + regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, + dither_strength=frame.dither_strength, manage=manage, + ) -# --- photos mode --- - -def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None, - is_normal_wake: bool) -> bytes: - if not _frame_configured(frame): - return _setup_placeholder(frame, request, manage=manage) - client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - - with frame_locked(db, frame.id) as locked: - photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked)) - asset_id = locked.current_asset_id - - return render_asset(client, frame, asset_id, manage=manage) - - -def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: - require_configured(frame) - client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - - with frame_locked(db, frame.id) as locked: - photo_queue.advance_forced(locked, assets, locked) - asset_id = locked.current_asset_id - - return render_asset(client, frame, asset_id, manage=manage) - - -def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: - require_configured(frame) - client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - - with frame_locked(db, frame.id) as locked: - photo_queue.back_forced(locked, assets, locked) - asset_id = locked.current_asset_id - - return render_asset(client, frame, asset_id, manage=manage) - - -# --- calendar mode --- - -def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None, +def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None, is_normal_wake: bool) -> bytes: - from .common import calendar_sources_for_frame + """The top-level "what does this frame show right now" entry point. + An unclaimed frame or one with no widgets yet gets the setup + placeholder (needs `request` for its QR URLs -- only available on the + normal-wake path where a real request is on hand, never on an + advance/back button press); otherwise every widget on it gets + composited via _render_widgets. Individual widgets that are + themselves unconfigured show their own small placeholder within + their own region (see app/widgets/*.py) rather than blanking the + whole panel -- a partially-set-up multi-widget frame still shows + whatever IS configured.""" + has_widgets = frame.owner_user_id is not None and ( + db.scalars(select(Widget.id).where(Widget.frame_id == frame.id).limit(1)).first() is not None + ) + if not has_widgets: + if request is None: + return render_placeholder( + ["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage + ) + return _setup_placeholder(frame, request, manage=manage) - if not calendar_sources_for_frame(db, frame): - return render_placeholder( - ["This frame's calendar isn't set up yet", - "Add a calendar in Settings, then include it on", - "this frame's Configuration -> Calendar card."], - orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage, - ) + return _render_widgets(db, frame, manage, is_normal_wake) - with frame_locked(db, frame.id) as locked: - if is_normal_wake and locked.calendar_browse_offset != 0: - locked.calendar_browse_offset = 0 - browse_offset = locked.calendar_browse_offset - view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" - week_start = locked.calendar_week_start - week_days = locked.calendar_week_days - week_layout = locked.calendar_week_layout - week_start_offset = locked.calendar_week_start_offset - inlay_wanted = locked.calendar_photo_inlay - events, summary = get_or_refresh_calendar_events(db, frame) - weather_cities = get_or_refresh_weather(db, frame) - # Only ever shown on the week view (see calendar_render._build_week) -- - # gated here too so a disabled/other-view frame never pays for the - # fetch, and so None (not just an empty list) reaches render_calendar - # to mean "no tasks slot at all", distinct from "slot reserved but - # nothing outstanding right now". - tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None - - photo_inlay = None - if inlay_wanted and _frame_configured(frame): +def _run_button_actions(db: Session, frame: Frame, button: str) -> None: + """Executes every (widget, action) binding assigned to this physical + button, in order -- see models.FrameButtonAction and the button- + assignment UI (a later phase). Each action runs to completion (its + own widget_locked span) before the next one starts -- never nested, + since db.widget_locked's underlying lock isn't reentrant (see its own + docstring) -- a button assigned several actions would deadlock + instantly if this looped any other way. One action failing + unexpectedly doesn't block the others, or the eventual re-render, + from happening -- the user pressed a physical button and expects + *something* to happen even if one of several assigned widgets is + having a bad moment.""" + actions = db.scalars( + select(FrameButtonAction) + .where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button) + .order_by(FrameButtonAction.sort_order) + ).all() + for action_row in actions: + widget = db.get(Widget, action_row.widget_id) + if widget is None: + continue + module = WIDGET_TYPES.get(widget.widget_type) + action_fn = module.ACTIONS.get(action_row.action) if module else None + if action_fn is None: + continue try: - client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - with frame_locked(db, frame.id) as locked: - photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked)) - asset_id = locked.current_asset_id - if asset_id: - jpeg_bytes = client.download_asset_preview(asset_id) - import io - - from PIL import Image - - photo_inlay = Image.open(io.BytesIO(jpeg_bytes)) - except HTTPException: - pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda - - return calendar_render.render_calendar( - events, view=view, browse_offset=browse_offset, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, timezone=frame.timezone, - photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start, - weather_cities=weather_cities, weather_units=frame.calendar_weather_units, - week_days=week_days, week_layout=week_layout, tasks=tasks, week_start_offset=week_start_offset, - ) - - -def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: - """NEXT in calendar mode: moves the displayed period forward one step - (day for agenda, week for week view, month for month view) from - wherever it currently is -- not from "today" -- so repeated presses - walk further forward. See Frame.calendar_browse_offset.""" - with frame_locked(db, frame.id) as locked: - locked.calendar_browse_offset += 1 - return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False) - - -def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: - with frame_locked(db, frame.id) as locked: - locked.calendar_browse_offset -= 1 - return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False) - - -# --- whiteboard mode --- - -def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None, - is_normal_wake: bool) -> bytes: - """Fetches (throttled, see get_or_refresh_whiteboard) and renders the - frame's configured .whiteboard file. The rendered PNG is treated - exactly like a photo from here on -- run through the same - render_frame composition/quantization pipeline as photos mode, - letterboxed (never cropped: unlike a photo, losing part of a - whiteboard to a crop loses actual content, not just some background) - -- rather than a second parallel image pipeline just for this mode.""" - png_bytes = get_or_refresh_whiteboard(db, frame) - if png_bytes is None: - return render_placeholder( - ["This frame's whiteboard isn't set up yet", - "Add a WebDAV/Nextcloud whiteboard file URL on", - "this frame's Whiteboard tab."], - orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage, - ) - - source = Image.open(io.BytesIO(png_bytes)).convert("RGB") - return render_frame( - source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, - display_mode="letterbox", manage=manage, - ) - - -def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: - """NEXT in whiteboard mode: there's no "next" concept for a single - static board, so this instead forces an immediate re-fetch/re-render - bypassing the throttle -- a "check now" button for "someone just - updated the board, show it right away" rather than waiting out - calendar_feed.CHECK_INTERVAL_S.""" - with frame_locked(db, frame.id) as locked: - locked.whiteboard_checked_at = 0.0 - return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False) - - -def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: - """Same "check now" behavior as _advance_whiteboard_mode -- there's - no separate "back" concept for a single static board either.""" - return _advance_whiteboard_mode(db, frame, manage) - - -RENDERERS = { - "photos": _render_photos_mode, - "calendar": _render_calendar_mode, - "whiteboard": _render_whiteboard_mode, -} -ADVANCE_RENDERERS = { - "photos": _advance_photos_mode, - "calendar": _advance_calendar_mode, - "whiteboard": _advance_whiteboard_mode, -} -BACK_RENDERERS = { - "photos": _back_photos_mode, - "calendar": _back_calendar_mode, - "whiteboard": _back_whiteboard_mode, -} + action_fn(db, frame, widget) + except Exception: + logger.exception( + "Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id + ) @router.get("/frame/config") @@ -311,42 +207,47 @@ def _manage_flag(request: Request) -> bool: def frame_image( request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db) ): - """Returns the frame's current image. For photos mode: idempotent -- - only actually advances to the next photo once refresh_interval_s has - elapsed since the current one was set (see app/photo_queue.py) -- - safe to call as often as the device wants, including after an - unplanned reboot, without skipping ahead in the album. An unclaimed/ - unconfigured frame gets a rendered instruction placeholder (200, not - an error) so a fresh device never error-loops. + """Returns the frame's current image -- every widget on the frame + composited into one panel (see _render_widgets). Each widget's own + render is idempotent in whatever way makes sense for its type (e.g. + a photo widget only actually advances once its own refresh interval + has elapsed, see app/photo_queue.py) -- safe to call as often as the + device wants, including after an unplanned reboot, without skipping + ahead. An unclaimed frame or one with no widgets yet gets a rendered + instruction placeholder (200, not an error) so a fresh device never + error-loops. ?manage=1 (the manage button) composites the manage overlay onto whatever this would have returned anyway -- see build_manage_content. - For calendar mode, this is also the "normal wake" that resets - calendar_browse_offset back to 0 (see _render_calendar_mode).""" - renderer = RENDERERS.get(frame.mode, _render_photos_mode) + This is also the "normal wake" that resets any calendar widget's + browse position back to today (see app/widgets/calendar.py).""" manage = build_manage_content(db, frame, request) if _manage_flag(request) else None - content = renderer(db, frame, request, manage, True) + content = _render_frame_content(db, frame, request, manage, is_normal_wake=True) return Response(content=content, media_type="application/octet-stream") @router.post("/frame/advance") def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): - """Forces an immediate move forward -- the next photo in photos mode, - or the next day/week/month in calendar mode -- ignoring - refresh_interval_s. Used by the device's next-photo button.""" - handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode) + """Forces an immediate move forward on whatever widget(s) the NEXT + button is assigned to (see models.FrameButtonAction) -- e.g. the next + photo for a photo widget, or the next day/week/month for a calendar + widget -- then re-renders and returns the whole panel. Used by the + device's next-photo button.""" + _run_button_actions(db, frame, "next") manage = build_manage_content(db, frame, request) if _manage_flag(request) else None - return Response(content=handler(db, frame, manage), media_type="application/octet-stream") + content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False) + return Response(content=content, media_type="application/octet-stream") @router.post("/frame/back") def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): - """The mirror of /frame/advance -- back a photo in photos mode, back - a period in calendar mode. A no-op (still 200, unchanged) if there's - nothing to go back to. Used by the device's back-photo button.""" - handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode) + """The mirror of /frame/advance, for whatever widget(s) the BACK + button is assigned to. A no-op (still 200, unchanged) for any widget + with nothing to go back to. Used by the device's back-photo button.""" + _run_button_actions(db, frame, "back") manage = build_manage_content(db, frame, request) if _manage_flag(request) else None - return Response(content=handler(db, frame, manage), media_type="application/octet-stream") + content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False) + return Response(content=content, media_type="application/octet-stream") class BatteryReport(BaseModel): @@ -441,18 +342,25 @@ def frame_firmware(frame: Frame = Depends(require_device)): @router.get("/frame/share/{asset_id}") -def frame_share(asset_id: str, frame: Frame = Depends(require_device)): +def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): """Creates a 30-minute public Immich share link for asset_id and redirects to it -- what the manage overlay's bottom-left QR code points to. The link is created lazily, when this actually gets hit (i.e. when someone scans it), not when the manage button was pressed, so the 30-minute window starts when it's actually used. - Also scoped to the photo currently showing or queued on THIS frame -- - not any arbitrary Immich asset id -- as a second layer even a leaked - token wouldn't bypass.""" - require_configured(frame) + Also scoped to the photo currently showing or queued on one of THIS + frame's own photo widgets -- not any arbitrary Immich asset id -- as + a second layer even a leaked token wouldn't bypass.""" + url, key = immich_creds(frame) + if not url or not key: + raise HTTPException(400, "Immich URL/API key not configured yet") - if asset_id != frame.current_asset_id and asset_id not in frame.queue: + photo_widgets = photo_widgets_for_frame(db, frame) + showing_or_queued = any( + asset_id == cfg.current_asset_id or asset_id in cfg.queue + for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets) + ) + if not showing_or_queued: raise HTTPException(404, "That photo isn't currently showing or queued on this frame") client = immich_client_for(frame) diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index be46090..3039453 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -20,9 +20,9 @@ from ..image_pipeline import ( PALETTE_LABELS, palette_to_hex, ) -from ..models import Frame, FrameCalendar, User, UserFrame +from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig from ..quiet_hours import ALL_TIMEZONES -from .common import shell_context +from .common import shell_context, widget_of_type router = APIRouter() templates = Jinja2Templates(directory="app/templates") @@ -36,15 +36,27 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab if frame is None or not can_view_frame(db, user, frame): raise HTTPException(404, "No such frame") ctx = shell_context(request, db, user, active_frame=frame) - ctx.update({"frame": frame, "active_tab": tab, **extra}) + ctx.update({ + "frame": frame, "active_tab": tab, + "has_calendar_widget": widget_of_type(db, frame, "calendar") is not None, + "has_whiteboard_widget": widget_of_type(db, frame, "whiteboard") is not None, + **extra, + }) return templates.TemplateResponse(template, ctx) @router.get("/frames/{frame_id}", response_class=HTMLResponse) def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + frame = db.get(Frame, frame_id) + photo_cfg = None + if frame is not None: + photo_widget = widget_of_type(db, frame, "photos") + if photo_widget is not None: + photo_cfg = db.get(PhotoWidgetConfig, photo_widget.id) return _frame_page( request, db, frame_id, "frame_photos.html", "photos", display_mode_labels=DISPLAY_MODE_LABELS, + photo_cfg=photo_cfg, ) @@ -99,19 +111,19 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) return result -def _tasks_source_info(db: Session, frame: Frame) -> dict | None: +def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None: """Whose CalDAV calendar this frame's week-view task list currently pulls from, and its label -- for showing "using 's Chores list" to everyone linked, not just whoever set it. None if no source is configured.""" - if not frame.calendar_tasks_user_id or not frame.calendar_tasks_calendar_key: + if calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key: return None - user = db.get(User, frame.calendar_tasks_user_id) + user = db.get(User, calendar_cfg.tasks_user_id) if user is None: return None - label = frame.calendar_tasks_calendar_key + label = calendar_cfg.tasks_calendar_key for c in (user.calendar_caldav_calendars or []): - if f"caldav:{c['href']}" == frame.calendar_tasks_calendar_key: + if f"caldav:{c['href']}" == calendar_cfg.tasks_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} @@ -137,8 +149,13 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g viewer = current_user(request, db) frame = db.get(Frame, frame_id) viewer_task_calendars = [] + calendar_cfg = None if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")] + if frame is not None: + calendar_widget = widget_of_type(db, frame, "calendar") + if calendar_widget is not None: + calendar_cfg = db.get(CalendarWidgetConfig, calendar_widget.id) return _frame_page( request, db, frame_id, "frame_calendar.html", "calendar", calendar_views=CALENDAR_VIEW_LABELS, @@ -148,20 +165,21 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g default_palette_rgb=DEFAULT_PALETTE_RGB, palette_to_hex=palette_to_hex, viewer_task_calendars=viewer_task_calendars, - tasks_source=_tasks_source_info(db, frame) if frame is not None else None, + calendar_cfg=calendar_cfg, + tasks_source=_tasks_source_info(db, calendar_cfg), ) -def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None: +def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig | None) -> dict | None: """Whose account this frame's whiteboard currently fetches with, for showing "using 's account" to everyone linked, not just whoever set it. None if no source is configured.""" - if not frame.whiteboard_user_id or not frame.whiteboard_url: + if whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url: return None - user = db.get(User, frame.whiteboard_user_id) + user = db.get(User, whiteboard_cfg.user_id) if user is None: return None - return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url} + return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url} @router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse) @@ -169,13 +187,18 @@ def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends viewer = current_user(request, db) frame = db.get(Frame, frame_id) viewer_has_webdav_creds = False + whiteboard_cfg = None if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): viewer_has_webdav_creds = bool( viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username) ) + if frame is not None: + whiteboard_widget = widget_of_type(db, frame, "whiteboard") + if whiteboard_widget is not None: + whiteboard_cfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id) return _frame_page( request, db, frame_id, "frame_whiteboard.html", "whiteboard", - whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None, + whiteboard_source=_whiteboard_source_info(db, whiteboard_cfg), viewer_has_webdav_creds=viewer_has_webdav_creds, ) diff --git a/server/app/routers/manage.py b/server/app/routers/manage.py index 40d5bec..820fdf2 100644 --- a/server/app/routers/manage.py +++ b/server/app/routers/manage.py @@ -18,9 +18,9 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .. import photo_queue, quiet_hours -from ..db import frame_locked, get_db +from ..db import get_db, widget_locked from ..models import Frame -from .common import immich_client_for, list_assets, require_configured +from .common import immich_client_for, list_assets, photo_widget_config_or_404 logger = logging.getLogger(__name__) @@ -46,15 +46,16 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d @router.get("/api/m/{manage_token}/queue") def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)): - require_configured(frame) + photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) + assets = list_assets(client, pcfg.album_id) - with frame_locked(db, frame.id) as cfg: - photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) - photo_queue.sync_queue_length(cfg, assets) - current = cfg.current_asset_id - queue = list(cfg.queue) + with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.get_current(locked_pcfg, assets, locked_frame, + in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) + photo_queue.sync_queue_length(locked_pcfg, assets) + current = locked_pcfg.current_asset_id + queue = list(locked_pcfg.queue) def entry(asset_id: str) -> dict: return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"} @@ -76,7 +77,8 @@ def manage_promote( frame: Frame = Depends(require_manage), db: Session = Depends(get_db), ): - with frame_locked(db, frame.id) as cfg: + photo_widget, _ = photo_widget_config_or_404(db, frame) + with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg): if body.asset_id not in cfg.queue: raise HTTPException(400, "That photo is no longer in the upcoming queue") cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id] @@ -87,29 +89,30 @@ def manage_promote( def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)): """Advances the server-side current photo; the panel itself updates on the device's next wake (or its next-photo button).""" - require_configured(frame) + photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - with frame_locked(db, frame.id) as cfg: - photo_queue.advance_forced(cfg, assets, cfg) + assets = list_assets(client, pcfg.album_id) + with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.advance_forced(locked_pcfg, assets, locked_frame) return {"status": "saved"} @router.post("/api/m/{manage_token}/back") def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)): - require_configured(frame) + photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) - assets = list_assets(client, frame.album_id) - with frame_locked(db, frame.id) as cfg: - photo_queue.back_forced(cfg, assets, cfg) + assets = list_assets(client, pcfg.album_id) + with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.back_forced(locked_pcfg, assets, locked_frame) return {"status": "saved"} @router.get("/api/m/{manage_token}/thumbnail/{asset_id}") -def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)): +def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)): """Thumbnails scoped to what this frame is actually showing/queuing -- the manage token must not become a general Immich proxy.""" - if asset_id != frame.current_asset_id and asset_id not in frame.queue: + _, pcfg = photo_widget_config_or_404(db, frame) + if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue: raise HTTPException(404, "Not on this frame") client = immich_client_for(frame) try: diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js index 233adae..6343798 100644 --- a/server/app/static/frame_calendar.js +++ b/server/app/static/frame_calendar.js @@ -1,7 +1,7 @@ -// Calendar tab: view/week-start/photo-inlay settings, per-user opt-in, -// and the rendered preview. Extracted from frame_config.js when the -// Calendar card became its own tab (window.FRAME_API is set by the -// template; checkboxes are always sent explicitly as "true"/"false"). +// Calendar tab: view/week-start settings, per-user opt-in, and the +// rendered preview. Extracted from frame_config.js when the Calendar +// card became its own tab (window.FRAME_API is set by the template; +// checkboxes are always sent explicitly as "true"/"false"). // Week-view-only settings (days/layout/start-offset) only matter when // View is actually "Week"; "Week starts on" also matters for Month, so @@ -31,7 +31,6 @@ document.getElementById('calendar-config-form').addEventListener('submit', async calendar_week_days: document.getElementById('calendar_week_days').value, calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, - calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked), }); try { const resp = await fetch(`${window.FRAME_API}/config`, { diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js index c5b00e3..8bb29c1 100644 --- a/server/app/static/frame_header.js +++ b/server/app/static/frame_header.js @@ -1,8 +1,8 @@ // Page-header controls shared by every per-frame page (Photos/ -// Configuration/Calendar/Stats): the frame-name pencil-edit and the -// mode selector, both now living outside the tab structure since they -// apply regardless of which tab is open. Depends on window.FRAME_API -// (set per-page) and common.js's showStatus/apiError. +// Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit, +// living outside the tab structure since it applies regardless of which +// tab is open. Depends on window.FRAME_API (set per-page) and +// common.js's showStatus/apiError. (function () { var view = document.getElementById('frame-name-view'); @@ -55,31 +55,3 @@ if (e.key === 'Escape') closeEdit(); }); })(); - -(function () { - var sel = document.getElementById('frame-mode-select'); - if (!sel || !window.FRAME_API) return; - var previous = sel.value; - - sel.addEventListener('change', async function () { - var mode = sel.value; - try { - const resp = await fetch(`${window.FRAME_API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ mode }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - previous = mode; - var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' }; - showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`); - var calTab = document.querySelector('.tabs a[href$="/calendar"]'); - if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar'); - var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]'); - if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard'); - } catch (e) { - sel.value = previous; - showStatus(false, e.message); - } - }); -})(); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index d57d199..3d521bb 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -514,8 +514,6 @@ code { } .frame-name-edit button { margin-top: 0; } -.frame-mode-select { width: auto; margin-top: 0; padding: 7px 10px; font-size: 13px; font-weight: 600; } - .control-banner { display: flex; align-items: center; @@ -558,9 +556,6 @@ code { .device-status-row { column-gap: 16px; } } -.mode-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; } -.mode-picker-label { font-size: 13px; font-weight: 600; color: var(--text-muted); } - /* Mobile: sidebar off-canvas, hamburger in a slim top bar. */ .mobile-bar { display: none; } .sidebar-backdrop { display: none; } diff --git a/server/app/templates/_frame_mode_picker.html b/server/app/templates/_frame_mode_picker.html deleted file mode 100644 index dc4e26e..0000000 --- a/server/app/templates/_frame_mode_picker.html +++ /dev/null @@ -1,4 +0,0 @@ -
- Mode - {% include "_frame_mode_select.html" %} -
diff --git a/server/app/templates/_frame_mode_select.html b/server/app/templates/_frame_mode_select.html deleted file mode 100644 index a0a0577..0000000 --- a/server/app/templates/_frame_mode_select.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/server/app/templates/_frame_tabs.html b/server/app/templates/_frame_tabs.html index 684339f..bd949e0 100644 --- a/server/app/templates/_frame_tabs.html +++ b/server/app/templates/_frame_tabs.html @@ -2,8 +2,8 @@ Photos Configuration Calendar + class="{% if active_tab == 'calendar' %}active{% endif %} {% if not has_calendar_widget %}tab-disabled{% endif %}">Calendar Whiteboard + class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if not has_whiteboard_widget %}tab-disabled{% endif %}">Whiteboard Stats diff --git a/server/app/templates/app_base.html b/server/app/templates/app_base.html index cad687f..48b9793 100644 --- a/server/app/templates/app_base.html +++ b/server/app/templates/app_base.html @@ -73,7 +73,6 @@ {% block device_status %}{% endblock %} - {% block mode_picker %}{% endblock %} {% block tabs %}{% endblock %} {% block content %}{% endblock %} diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html index e1d3e8b..98990b9 100644 --- a/server/app/templates/frame_calendar.html +++ b/server/app/templates/frame_calendar.html @@ -2,7 +2,6 @@ {% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} -{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} @@ -13,10 +12,9 @@ - {% if frame.mode != 'calendar' %} -
This frame is currently in Photos mode -- - settings below take effect once you switch it to Calendar mode - using the selector at the top of the page.
+ {% if not has_calendar_widget %} +
This frame doesn't have a Calendar widget on + screen yet -- settings below won't show up anywhere until one is added.
{% endif %}
@@ -27,7 +25,7 @@ @@ -35,7 +33,7 @@ @@ -43,30 +41,24 @@

0 = starts today, negative = starts in the past, positive = starts in the future. Only used when Days to show isn't 7.

-
- - -
-

- Uses the same album configured on the Photos tab -- nothing extra to set up.

@@ -112,8 +104,8 @@ {% endfor %} - {% if frame.calendar_fetch_summary %} -

Last fetch: {{ frame.calendar_fetch_summary }}

+ {% if calendar_cfg and calendar_cfg.fetch_summary %} +

Last fetch: {{ calendar_cfg.fetch_summary }}

{% endif %} @@ -123,13 +115,13 @@ & tomorrow), and Week views -- there's no room for it on Month.

- +
@@ -139,7 +131,7 @@

Every city shows on every day -- add more than one if people split their time between places.

    - {% for c in frame.calendar_weather_cities or [] %} + {% for c in (calendar_cfg.weather_cities if calendar_cfg else []) or [] %}
  • {{ c.label }} @@ -159,7 +151,7 @@

    Week view only -- takes the place of one day slot instead of adding an extra one.

    - +
    @@ -179,7 +171,7 @@ {% for c in viewer_task_calendars %}
  • + {% if tasks_source and tasks_source.user_id == user.id and calendar_cfg and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
  • {% endfor %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index b4a2c0c..1faf756 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -2,7 +2,6 @@ {% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} -{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} diff --git a/server/app/templates/frame_photos.html b/server/app/templates/frame_photos.html index fdb3107..62ce625 100644 --- a/server/app/templates/frame_photos.html +++ b/server/app/templates/frame_photos.html @@ -2,7 +2,6 @@ {% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} -{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} @@ -20,26 +19,26 @@ diff --git a/server/app/templates/frame_stats.html b/server/app/templates/frame_stats.html index 223aa9f..fc66e25 100644 --- a/server/app/templates/frame_stats.html +++ b/server/app/templates/frame_stats.html @@ -2,7 +2,6 @@ {% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} -{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} diff --git a/server/app/templates/frame_whiteboard.html b/server/app/templates/frame_whiteboard.html index ed29c3c..f627fcc 100644 --- a/server/app/templates/frame_whiteboard.html +++ b/server/app/templates/frame_whiteboard.html @@ -2,7 +2,6 @@ {% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} -{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} @@ -13,10 +12,9 @@ - {% if frame.mode != 'whiteboard' %} -
    This frame is currently in {{ frame.mode|capitalize }} mode -- - settings below take effect once you switch it to Whiteboard mode - using the selector at the top of the page.
    + {% if not has_whiteboard_widget %} +
    This frame doesn't have a Whiteboard widget on + screen yet -- settings below won't show up anywhere until one is added.
    {% endif %}
    diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index 2d441de..a3e1315 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -6,14 +6,19 @@ optionally responds to named button actions." Each module in this package exposes: - render(db, frame, widget, target_w, target_h) -> Image.Image + render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image An RGB image exactly target_w x target_h, unquantized -- the widget's content composed into its own region. Never returns packed panel bytes or raises for a foreseeable failure (a widget's own fetch hiccup shows a small placeholder instead) -- image_pipeline.render_panel composites every widget's own render() result onto one shared canvas and quantizes/packs the - whole thing once (see its own docstring). + whole thing once (see its own docstring). is_normal_wake + distinguishes an ordinary /frame/image GET from a button- + triggered re-render -- only app/widgets/calendar.py's render() + actually uses it (resetting browse_offset back to "today" on a + normal wake), but every module accepts it for one uniform call + signature regardless. ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]] Named button actions this widget type supports (e.g. "advance", diff --git a/server/app/widgets/calendar.py b/server/app/widgets/calendar.py index 5e2ac1e..83b6038 100644 --- a/server/app/widgets/calendar.py +++ b/server/app/widgets/calendar.py @@ -34,7 +34,19 @@ from ..routers.common import ( ACTION_LABELS = {"advance": "Next period", "back": "Previous period"} -def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image: +def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int, + is_normal_wake: bool = True) -> Image.Image: + """is_normal_wake=True (an ordinary /frame/image GET, not a button + press) resets browse_offset back to "today" if it had drifted -- + mirrors the old _render_calendar_mode's identical behavior. A button + press explicitly moved the browse position on purpose, so it passes + is_normal_wake=False to render its own already-updated offset instead + of immediately snapping back to 0.""" + if is_normal_wake: + with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg): + if locked_cfg.browse_offset != 0: + locked_cfg.browse_offset = 0 + 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 diff --git a/server/app/widgets/photos.py b/server/app/widgets/photos.py index c0fc27c..ee04f33 100644 --- a/server/app/widgets/photos.py +++ b/server/app/widgets/photos.py @@ -26,7 +26,13 @@ from ._shared import placeholder_image ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"} -def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image: +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 -- photos mode's advance timing is + already fully idempotent via get_current()'s own elapsed-time check, + unlike calendar mode's browse_offset (see app/widgets/calendar.py's + render()). Accepted anyway so every widget type's render() shares one + call signature regardless of which ones actually care.""" cfg = db.get(PhotoWidgetConfig, widget.id) if not cfg.album_id: return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"]) diff --git a/server/app/widgets/whiteboard.py b/server/app/widgets/whiteboard.py index fa2397e..85cdebf 100644 --- a/server/app/widgets/whiteboard.py +++ b/server/app/widgets/whiteboard.py @@ -22,7 +22,11 @@ from ._shared import placeholder_image ACTION_LABELS = {"check_now": "Check for updates"} -def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image: +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.""" png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget) if png_bytes is None: return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"]) diff --git a/server/tests/test_device_widget_dispatch.py b/server/tests/test_device_widget_dispatch.py new file mode 100644 index 0000000..5998598 --- /dev/null +++ b/server/tests/test_device_widget_dispatch.py @@ -0,0 +1,177 @@ +"""End-to-end HTTP tests for the widget-system cutover in +routers/device.py -- /frame/image, /frame/advance, /frame/back, and +/frame/share against real widget rows (via the migration-backfilled +frame #1, or a purpose-built second frame), a real TestClient, real +render_panel/compose_into. Only Immich itself is mocked (monkeypatched +at the app.widgets.photos module boundary, same pattern as +test_widgets_photos.py) -- everything else in the pipeline is real. + +This is the one place in the suite that actually exercises +routers/device.py's dispatch over HTTP; the render-size-invariant tests +exercise the renderers directly, and the widget unit tests exercise +app/widgets/*.py directly, but neither proves device.py's own wiring +(the compositor, the button-action runner, the manage-overlay hookup) +is correct -- that's what this file is for.""" + +from __future__ import annotations + +import time + +from app import widgets +from app.models import ( + CalendarWidgetConfig, + Frame, + FrameButtonAction, + PhotoWidgetConfig, + User, + Widget, +) + +EXPECTED_BYTES = 800 * 480 // 2 +_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}] + + +def _mock_immich(monkeypatch): + monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) + monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS) + from PIL import Image + source = Image.new("RGB", (100, 80), (10, 20, 30)) + monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None)) + + +def test_unclaimed_frame_shows_placeholder(client, db_session): + frame = db_session.get(Frame, 1) + frame.owner_user_id = None + db_session.commit() + + resp = client.get("/frame/image") + assert resp.status_code == 200 + assert len(resp.content) == EXPECTED_BYTES + + +def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + resp = client.get("/frame/image") + assert resp.status_code == 200 + assert len(resp.content) == EXPECTED_BYTES + + frame = db_session.get(Frame, 1) + widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.album_id == "" # never configured -- still rendered fine, as a placeholder region + + +def test_configured_photo_widget_renders_and_advances_via_button(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + cfg = db_session.get(PhotoWidgetConfig, widget.id) + cfg.album_id = "album-1" + db_session.commit() + + _mock_immich(monkeypatch) + + resp = client.get("/frame/image") + assert resp.status_code == 200 + assert len(resp.content) == EXPECTED_BYTES + + db_session.refresh(cfg) + assert cfg.current_asset_id == "asset-1" + + resp = client.post("/frame/advance") + assert resp.status_code == 200 + assert len(resp.content) == EXPECTED_BYTES + db_session.refresh(cfg) + assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired + + resp = client.post("/frame/back") + assert resp.status_code == 200 + db_session.refresh(cfg) + assert cfg.current_asset_id == "asset-1" # back undid it + + +def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1" + db_session.commit() + _mock_immich(monkeypatch) + + plain = client.get("/frame/image").content + with_manage = client.get("/frame/image?manage=1").content + assert len(with_manage) == EXPECTED_BYTES + assert with_manage != plain # the manage-QR overlay actually got composited in + + +def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_session, monkeypatch): + """A second frame (not frame #1) with two independent widgets -- a + calendar widget and a photo widget, addressed directly the way the + migration's calendar_photo_inlay backfill shapes a frame -- exercised + end to end over HTTP, not just via the migration's own unit tests.""" + _mock_immich(monkeypatch) + + frame = Frame( + name="Two Widget Frame", device_id="aabbccddeeff", device_token="devtok-2", + manage_token="mtok-2", orientation="landscape", created_at=time.time(), + ) + db_session.add(frame) + db_session.flush() + + cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=4, h=5, + sort_order=0, created_at=time.time()) + photo_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5, + sort_order=1, created_at=time.time()) + db_session.add_all([cal_widget, photo_widget]) + db_session.flush() + db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id, view="agenda", browse_offset=0)) + db_session.add(PhotoWidgetConfig(widget_id=photo_widget.id, album_id="album-1")) + db_session.add_all([ + FrameButtonAction(frame_id=frame.id, button="next", widget_id=cal_widget.id, action="advance"), + FrameButtonAction(frame_id=frame.id, button="back", widget_id=cal_widget.id, action="back"), + ]) + db_session.commit() + + resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}") + assert resp.status_code == 200 + assert len(resp.content) == EXPECTED_BYTES + + # NEXT is bound only to the calendar widget -- pressing it should + # move the calendar's browse_offset, not touch the photo widget. + resp = client.post(f"/frame/advance?id={frame.device_id}&token={frame.device_token}") + assert resp.status_code == 200 + + cal_cfg = db_session.get(CalendarWidgetConfig, cal_widget.id) + photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id) + assert cal_cfg.browse_offset == 1 + assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it + + +def test_frame_share_checks_widget_scoped_state(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + frame.owner_user_id = db_session.query(User).filter_by(username="alice").one().id + widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + cfg = db_session.get(PhotoWidgetConfig, widget.id) + cfg.album_id = "album-1" + cfg.current_asset_id = "asset-1" + cfg.queue = ["asset-2"] + frame.immich_url = "http://immich.example.com" + frame.immich_api_key = "key" + db_session.commit() + + # Not showing/queued -- rejected before ever touching Immich + resp = client.get("/frame/share/asset-not-on-this-frame") + assert resp.status_code == 404 + + # Currently showing -- allowed through to the (mocked) Immich call + monkeypatch.setattr( + "app.routers.device.immich_client_for", + lambda frame: type("C", (), {"create_share_link": lambda self, asset_id, expires_in_s: "https://immich.example.com/share/abc"})(), + ) + resp = client.get("/frame/share/asset-1", follow_redirects=False) + assert resp.status_code in (302, 303, 307) + + # Queued (not current) -- also allowed + resp = client.get("/frame/share/asset-2", follow_redirects=False) + assert resp.status_code in (302, 303, 307) diff --git a/server/tests/test_manage_widget_dispatch.py b/server/tests/test_manage_widget_dispatch.py new file mode 100644 index 0000000..77a1b97 --- /dev/null +++ b/server/tests/test_manage_widget_dispatch.py @@ -0,0 +1,121 @@ +"""routers/manage.py -- the no-login "scan to manage" surface -- against +the widget-scoped photo state it was just repointed at. No prior test +coverage existed for this router at all before this file; it's +exercised here specifically because of how much its photo-queue +endpoints changed in the widget-system cutover (frame.current_asset_id/ +frame.queue -> the frame's photo widget's own PhotoWidgetConfig).""" + +from __future__ import annotations + +from app.models import Frame, PhotoWidgetConfig + +_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}] + + +def _mock_immich(monkeypatch): + """manage.py imports immich_client_for/list_assets into its own + module namespace (`from .common import ...`) -- patching those + names, not app.widgets.photos' separate copies, since manage.py's + endpoints call its own bound names directly, never through + app/widgets/.""" + monkeypatch.setattr("app.routers.manage.immich_client_for", lambda frame: object()) + monkeypatch.setattr("app.routers.manage.list_assets", lambda client, album_id: _ASSETS) + + +def _configure_photo_widget(db_session): + from app.models import Widget + + frame = db_session.get(Frame, 1) + # photo_widget_config_or_404 (see routers/common.py) checks Immich + # creds directly, not through the mocked immich_client_for below. + frame.immich_url = "http://immich.example.com" + frame.immich_api_key = "key" + widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + cfg = db_session.get(PhotoWidgetConfig, widget.id) + cfg.album_id = "album-1" + db_session.commit() + return frame, widget + + +def test_manage_queue_requires_configured_photo_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + resp = client.get(f"/api/m/{frame.manage_token}/queue") + assert resp.status_code == 400 + + +def test_manage_queue_returns_current_and_upcoming(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame, widget = _configure_photo_widget(db_session) + _mock_immich(monkeypatch) + + resp = client.get(f"/api/m/{frame.manage_token}/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"] + + +def test_manage_advance_and_back(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame, widget = _configure_photo_widget(db_session) + _mock_immich(monkeypatch) + + # establish a current photo first + client.get(f"/api/m/{frame.manage_token}/queue") + + resp = client.post(f"/api/m/{frame.manage_token}/advance") + assert resp.status_code == 200 + cfg = db_session.get(PhotoWidgetConfig, widget.id) + first_current = cfg.current_asset_id + assert first_current != "" + + resp = client.post(f"/api/m/{frame.manage_token}/advance") + assert resp.status_code == 200 + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.current_asset_id != first_current + + resp = client.post(f"/api/m/{frame.manage_token}/back") + assert resp.status_code == 200 + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.current_asset_id == first_current + + +def test_manage_promote(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame, widget = _configure_photo_widget(db_session) + _mock_immich(monkeypatch) + client.get(f"/api/m/{frame.manage_token}/queue") # populate the queue + + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert "asset-3" in cfg.queue + + resp = client.post(f"/api/m/{frame.manage_token}/promote", json={"asset_id": "asset-3"}) + assert resp.status_code == 200, resp.text + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.queue[0] == "asset-3" + + +def test_manage_thumbnail_scoped_to_showing_or_queued(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame, widget = _configure_photo_widget(db_session) + _mock_immich(monkeypatch) + client.get(f"/api/m/{frame.manage_token}/queue") + + resp = client.get(f"/api/m/{frame.manage_token}/thumbnail/not-on-this-frame") + assert resp.status_code == 404 + + monkeypatch.setattr( + "app.routers.manage.immich_client_for", + lambda frame: type("C", (), { + "download_asset_thumbnail": lambda self, asset_id: (b"jpegbytes", "image/jpeg"), + })(), + ) + resp = client.get(f"/api/m/{frame.manage_token}/thumbnail/asset-1") + assert resp.status_code == 200 + assert resp.content == b"jpegbytes" + + +def test_unknown_manage_token_404s(client, db_session): + resp = client.get("/api/m/not-a-real-token/queue") + assert resp.status_code == 404 diff --git a/server/tests/test_permission_boundaries.py b/server/tests/test_permission_boundaries.py index 80f1f93..d30d0ea 100644 --- a/server/tests/test_permission_boundaries.py +++ b/server/tests/test_permission_boundaries.py @@ -6,7 +6,16 @@ not just a helper function's logic.""" from __future__ import annotations -from app.models import Frame, FrameCalendar, User +import time + +from app.models import ( + CalendarWidgetConfig, + Frame, + FrameCalendar, + User, + Widget, + WhiteboardWidgetConfig, +) from .conftest import csrf_headers, link_user, login, make_user @@ -21,26 +30,53 @@ def _setup_two_linked_users(client, db_session) -> Frame: return frame +def _add_whiteboard_widget(db_session, frame: Frame) -> Widget: + """Frame #1's auto-migrated widget is a photos widget (its mode was + "photos" before the widget system existed) -- these tests need a + whiteboard widget too, which nothing creates yet until the + widget-placement UI (a later phase) ships, so it's added directly + here the same way the widget unit tests do.""" + widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(WhiteboardWidgetConfig(widget_id=widget.id)) + db_session.commit() + return widget + + +def _add_calendar_widget(db_session, frame: Frame) -> Widget: + widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5, + 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 + + # --- whiteboard-source --- def test_whiteboard_source_owner_can_set_it(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_whiteboard_widget(db_session, frame) # alice is still logged in from /setup resp = client.post("/api/frames/1/whiteboard-source", json={ "url": "https://cloud.example.com/dav/files/alice/board.whiteboard", }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - frame = db_session.get(Frame, 1) - assert frame.whiteboard_user_id is not None - assert frame.whiteboard_url == "https://cloud.example.com/dav/files/alice/board.whiteboard" + cfg = db_session.get(WhiteboardWidgetConfig, widget.id) + assert cfg.user_id is not None + assert cfg.url == "https://cloud.example.com/dav/files/alice/board.whiteboard" def test_whiteboard_source_set_always_targets_the_caller(client, db_session): """bob has no way to point the frame at someone else's account -- there's no target-user field in the request at all, so a "set" call from bob always attaches to bob, even if he pastes alice's URL.""" - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_whiteboard_widget(db_session, frame) client.cookies.clear() login(client, "bob") @@ -49,13 +85,14 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session): }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - frame = db_session.get(Frame, 1) bob_row = db_session.query(User).filter_by(username="bob").one() - assert frame.whiteboard_user_id == bob_row.id + cfg = db_session.get(WhiteboardWidgetConfig, widget.id) + assert cfg.user_id == bob_row.id def test_whiteboard_source_anyone_linked_can_clear(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_whiteboard_widget(db_session, frame) client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"}, headers=csrf_headers(client)) @@ -64,20 +101,22 @@ def test_whiteboard_source_anyone_linked_can_clear(client, db_session): resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - frame = db_session.get(Frame, 1) - assert frame.whiteboard_url == "" - assert frame.whiteboard_user_id is None + cfg = db_session.get(WhiteboardWidgetConfig, widget.id) + assert cfg.url == "" + assert cfg.user_id is None def test_whiteboard_source_rejects_non_http_url(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + _add_whiteboard_widget(db_session, frame) resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"}, headers=csrf_headers(client)) assert resp.status_code == 400 def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + _add_whiteboard_widget(db_session, frame) make_user(db_session, "mallory") # exists, but never linked to frame 1 client.cookies.clear() login(client, "mallory") @@ -87,10 +126,22 @@ def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session): assert resp.status_code == 404 +def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session): + """Distinct from the unlinked-user 404 above -- this is a linked, + fully-permitted owner hitting the endpoint on a frame that simply + doesn't have a whiteboard widget yet (frame #1's auto-migrated + widget is a photos widget).""" + _setup_two_linked_users(client, db_session) + resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"}, + headers=csrf_headers(client)) + assert resp.status_code == 404 + + # --- tasks-source --- def test_tasks_source_set_always_targets_the_caller(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_calendar_widget(db_session, frame) client.cookies.clear() login(client, "bob") @@ -99,13 +150,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() - frame = db_session.get(Frame, 1) - assert frame.calendar_tasks_user_id == bob_row.id - assert frame.calendar_tasks_calendar_key == "caldav:/some/tasks/" + cfg = db_session.get(CalendarWidgetConfig, widget.id) + assert cfg.tasks_user_id == bob_row.id + assert cfg.tasks_calendar_key == "caldav:/some/tasks/" def test_tasks_source_anyone_linked_can_clear(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_calendar_widget(db_session, frame) client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client)) @@ -114,9 +166,16 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session): resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - frame = db_session.get(Frame, 1) - assert frame.calendar_tasks_user_id is None - assert frame.calendar_tasks_calendar_key is None + cfg = db_session.get(CalendarWidgetConfig, widget.id) + assert cfg.tasks_user_id is None + assert cfg.tasks_calendar_key is None + + +def test_tasks_source_404s_when_frame_has_no_calendar_widget(client, db_session): + _setup_two_linked_users(client, db_session) + resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"}, + headers=csrf_headers(client)) + assert resp.status_code == 404 # --- calendar-select --- diff --git a/server/tests/test_whiteboard_refresh_and_browse.py b/server/tests/test_whiteboard_refresh_and_browse.py index 57f4dc7..2b0a789 100644 --- a/server/tests/test_whiteboard_refresh_and_browse.py +++ b/server/tests/test_whiteboard_refresh_and_browse.py @@ -1,63 +1,72 @@ -"""get_or_refresh_whiteboard's fetch throttle (and force=True bypassing -it) plus the whiteboard-browse HTTP endpoint. whiteboard.fetch_and_render -is monkeypatched -- it talks to a real WebDAV server and the Node render -sidecar (see render-service/), neither of which this suite needs a real -copy of to verify the *throttle*/*permission* logic around it.""" +"""get_or_refresh_whiteboard_for_widget's fetch throttle (and force=True +bypassing it) plus the whiteboard-browse HTTP endpoint. +whiteboard.fetch_and_render is monkeypatched -- it talks to a real +WebDAV server and the Node render sidecar (see render-service/), neither +of which this suite needs a real copy of to verify the *throttle*/ +*permission* logic around it.""" from __future__ import annotations import time from app import whiteboard -from app.models import Frame, User -from app.routers.common import get_or_refresh_whiteboard +from app.models import Frame, User, Widget, WhiteboardWidgetConfig from .conftest import csrf_headers, link_user, login, make_user -def _configure_whiteboard(db_session, frame: Frame, user: User) -> None: - frame.whiteboard_user_id = user.id - frame.whiteboard_url = "http://example.invalid/board.whiteboard" - frame.whiteboard_cached_image = b"OLD_CACHED_PNG" - frame.whiteboard_checked_at = time.time() # just refreshed -- well within the throttle +def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget: + widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(WhiteboardWidgetConfig( + widget_id=widget.id, user_id=user.id, url="http://example.invalid/board.whiteboard", + cached_image=b"OLD_CACHED_PNG", checked_at=time.time(), # just refreshed -- well within the throttle + )) db_session.commit() + return widget def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch): + from app.routers.common import get_or_refresh_whiteboard_for_widget + client.post("/setup", data={"username": "alice", "password": "hunter22"}) alice = db_session.query(User).filter_by(username="alice").one() alice.webdav_username = "alice" alice.webdav_password = "secret" frame = db_session.get(Frame, 1) - _configure_whiteboard(db_session, frame, alice) + widget = _configure_whiteboard(db_session, frame, alice) calls = [] monkeypatch.setattr(whiteboard, "fetch_and_render", lambda url, u, p: calls.append(1) or b"NEW_PNG") - result = get_or_refresh_whiteboard(db_session, frame) + result = get_or_refresh_whiteboard_for_widget(db_session, frame, widget) assert result == b"OLD_CACHED_PNG" assert calls == [] def test_force_bypasses_throttle_and_persists(client, db_session, monkeypatch): + from app.routers.common import get_or_refresh_whiteboard_for_widget + client.post("/setup", data={"username": "alice", "password": "hunter22"}) alice = db_session.query(User).filter_by(username="alice").one() alice.webdav_username = "alice" alice.webdav_password = "secret" frame = db_session.get(Frame, 1) - _configure_whiteboard(db_session, frame, alice) + widget = _configure_whiteboard(db_session, frame, alice) calls = [] monkeypatch.setattr(whiteboard, "fetch_and_render", lambda url, u, p: calls.append(1) or b"NEW_PNG") - result = get_or_refresh_whiteboard(db_session, frame, force=True) + result = get_or_refresh_whiteboard_for_widget(db_session, frame, widget, force=True) assert result == b"NEW_PNG" assert len(calls) == 1 - db_session.refresh(frame) - assert frame.whiteboard_cached_image == b"NEW_PNG" + cfg = db_session.get(WhiteboardWidgetConfig, widget.id) + assert cfg.cached_image == b"NEW_PNG" def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session, monkeypatch): @@ -67,10 +76,10 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session alice.webdav_password = "secret" frame = db_session.get(Frame, 1) - # The preview endpoint runs whatever get_or_refresh_whiteboard returns - # through PIL (Image.open) -- unlike the other tests in this file, - # the placeholder "cached" bytes need to be real, valid PNG data, not - # just an arbitrary marker string. + # The preview endpoint runs whatever get_or_refresh_whiteboard_for_widget + # returns through PIL (Image.open) -- unlike the other tests in this + # file, the placeholder "cached" bytes need to be real, valid PNG + # data, not just an arbitrary marker string. import io from PIL import Image @@ -78,10 +87,14 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session Image.new("RGB", (1, 1), (255, 255, 255)).save(buf, format="PNG") tiny_png = buf.getvalue() - frame.whiteboard_user_id = alice.id - frame.whiteboard_url = "http://example.invalid/board.whiteboard" - frame.whiteboard_cached_image = tiny_png - frame.whiteboard_checked_at = time.time() + widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(WhiteboardWidgetConfig( + widget_id=widget.id, user_id=alice.id, url="http://example.invalid/board.whiteboard", + cached_image=tiny_png, checked_at=time.time(), + )) db_session.commit() calls = [] diff --git a/server/tests/test_widgets_calendar.py b/server/tests/test_widgets_calendar.py index 69c91f2..e0fd64d 100644 --- a/server/tests/test_widgets_calendar.py +++ b/server/tests/test_widgets_calendar.py @@ -100,3 +100,28 @@ def test_back_action_decrements_browse_offset(db_session): widgets.calendar.ACTIONS["back"](db_session, frame, widget) cfg = db_session.get(CalendarWidgetConfig, widget.id) assert cfg.browse_offset == 2 + + +def test_normal_wake_resets_browse_offset_to_zero(db_session, monkeypatch): + """A plain /frame/image GET (is_normal_wake=True, the default) should + snap browse_offset back to "today" if a previous button press had + moved it -- mirrors the old _render_calendar_mode's identical + behavior. A button-triggered render (is_normal_wake=False) must NOT + do this, or every button press would immediately erase its own + effect on the very next render.""" + frame, widget = _make_widget(db_session, view="week", browse_offset=5) + _stub_fetches(monkeypatch) + + widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=True) + assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 0 + + +def test_button_triggered_render_does_not_reset_browse_offset(db_session, monkeypatch): + frame, widget = _make_widget(db_session, view="week", browse_offset=0) + _stub_fetches(monkeypatch) + + widgets.calendar.ACTIONS["advance"](db_session, frame, widget) + assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1 + + widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=False) + assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1