Widget system Phase 2: full cutover to widget-based rendering
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s

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.
This commit is contained in:
2026-07-24 09:26:28 -04:00
parent f48daa71c8
commit 37bd657299
26 changed files with 1070 additions and 791 deletions
+216 -174
View File
@@ -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"}
+157 -219
View File
@@ -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
+127 -219
View File
@@ -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)
+37 -14
View File
@@ -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 <name>'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 <name>'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,
)
+23 -20
View File
@@ -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:
+4 -5
View File
@@ -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`, {
+4 -32
View File
@@ -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);
}
});
})();
-5
View File
@@ -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; }
@@ -1,4 +0,0 @@
<div class="mode-picker-row">
<span class="mode-picker-label">Mode</span>
{% include "_frame_mode_select.html" %}
</div>
@@ -1,5 +0,0 @@
<select id="frame-mode-select" class="frame-mode-select" title="Frame mode" aria-label="Frame mode">
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
<option value="whiteboard" {% if frame.mode == "whiteboard" %}selected{% endif %}>Whiteboard</option>
</select>
+2 -2
View File
@@ -2,8 +2,8 @@
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/calendar"
class="{% if active_tab == 'calendar' %}active{% endif %} {% if frame.mode != 'calendar' %}tab-disabled{% endif %}">Calendar</a>
class="{% if active_tab == 'calendar' %}active{% endif %} {% if not has_calendar_widget %}tab-disabled{% endif %}">Calendar</a>
<a href="/frames/{{ frame.id }}/whiteboard"
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if frame.mode != 'whiteboard' %}tab-disabled{% endif %}">Whiteboard</a>
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if not has_whiteboard_widget %}tab-disabled{% endif %}">Whiteboard</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
-1
View File
@@ -73,7 +73,6 @@
</div>
</div>
{% block device_status %}{% endblock %}
{% block mode_picker %}{% endblock %}
{% block tabs %}{% endblock %}
{% block content %}{% endblock %}
</main>
+17 -25
View File
@@ -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 @@
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
{% if frame.mode != 'calendar' %}
<div class="info-box">This frame is currently in <strong>Photos</strong> mode --
settings below take effect once you switch it to <strong>Calendar</strong> mode
using the selector at the top of the page.</div>
{% if not has_calendar_widget %}
<div class="info-box">This frame doesn't have a <strong>Calendar</strong> widget on
screen yet -- settings below won't show up anywhere until one is added.</div>
{% endif %}
<div class="layout">
@@ -27,7 +25,7 @@
<label>View
<select id="calendar_view">
{% for value, label in calendar_views.items() %}
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option>
<option value="{{ value }}" {% if calendar_cfg and calendar_cfg.view == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
@@ -35,7 +33,7 @@
<label>Week starts on
<select id="calendar_week_start">
{% for value, label in week_start_labels.items() %}
<option value="{{ value }}" {% if frame.calendar_week_start == value %}selected{% endif %}>{{ label }}</option>
<option value="{{ value }}" {% if calendar_cfg and calendar_cfg.week_start == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
@@ -43,30 +41,24 @@
</div>
<div id="calendar-week-days-row">
<label>Days to show (Week view)
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ frame.calendar_week_days }}">
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ calendar_cfg.week_days if calendar_cfg else 7 }}">
</label>
</div>
<div id="calendar-week-layout-row">
<label>Week view layout
<select id="calendar_week_layout">
<option value="horizontal" {% if frame.calendar_week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
<option value="vertical" {% if frame.calendar_week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
<option value="horizontal" {% if not calendar_cfg or calendar_cfg.week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
<option value="vertical" {% if calendar_cfg and calendar_cfg.week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
</select>
</label>
</div>
<div id="calendar-week-offset-row">
<label>Week view starts (days from today)
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ frame.calendar_week_start_offset }}">
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ calendar_cfg.week_start_offset if calendar_cfg else 0 }}">
</label>
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div>
<div class="checkbox-row" id="calendar-inlay-row">
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
<label for="calendar_photo_inlay">Show a photo alongside the calendar</label>
</div>
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px;">
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
<button type="submit">Save</button>
</form>
@@ -112,8 +104,8 @@
{% endfor %}
</ul>
{% if frame.calendar_fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
{% if calendar_cfg and calendar_cfg.fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ calendar_cfg.fetch_summary }}</p>
{% endif %}
</section>
@@ -123,13 +115,13 @@
&amp; tomorrow), and Week views -- there's no room for it on Month.</p>
<form id="weather-config-form">
<div class="checkbox-row">
<input type="checkbox" id="weather_enabled" {% if frame.calendar_weather_enabled %}checked{% endif %}>
<input type="checkbox" id="weather_enabled" {% if calendar_cfg and calendar_cfg.weather_enabled %}checked{% endif %}>
<label for="weather_enabled">Show weather</label>
</div>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if frame.calendar_weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if frame.calendar_weather_units == "celsius" %}selected{% endif %}>Celsius</option>
<option value="fahrenheit" {% if not calendar_cfg or calendar_cfg.weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if calendar_cfg and calendar_cfg.weather_units == "celsius" %}selected{% endif %}>Celsius</option>
</select>
</label>
<button type="submit">Save</button>
@@ -139,7 +131,7 @@
<p class="sub">Every city shows on every day -- add more than one if
people split their time between places.</p>
<ul class="calendar-user-list" id="weather-city-list">
{% for c in frame.calendar_weather_cities or [] %}
{% for c in (calendar_cfg.weather_cities if calendar_cfg else []) or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
@@ -159,7 +151,7 @@
<p class="sub">Week view only -- takes the place of one day slot
instead of adding an extra one.</p>
<div class="checkbox-row" id="tasks-enabled-row">
<input type="checkbox" id="tasks_enabled" {% if frame.calendar_tasks_enabled %}checked{% endif %}>
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg and calendar_cfg.tasks_enabled %}checked{% endif %}>
<label for="tasks_enabled">Show a task list</label>
</div>
@@ -179,7 +171,7 @@
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and frame.calendar_tasks_calendar_key == c.key %}checked{% endif %}>
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
-1
View File
@@ -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 %}
+5 -6
View File
@@ -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 @@
<form id="photos-form">
<label>Album
<select id="album_id">
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
{% if photo_cfg and photo_cfg.album_id %}<option value="{{ photo_cfg.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
</select>
</label>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
<option value="{{ n }}" {% if photo_cfg and photo_cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
<option value="sequential" {% if not photo_cfg or photo_cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if photo_cfg and photo_cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Display mode
<select id="display_mode">
{% for mode, label in display_mode_labels.items() %}
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
<option value="{{ mode }}" {% if photo_cfg and photo_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
-1
View File
@@ -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 %}
+3 -5
View File
@@ -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 @@
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
{% if frame.mode != 'whiteboard' %}
<div class="info-box">This frame is currently in <strong>{{ frame.mode|capitalize }}</strong> mode --
settings below take effect once you switch it to <strong>Whiteboard</strong> mode
using the selector at the top of the page.</div>
{% if not has_whiteboard_widget %}
<div class="info-box">This frame doesn't have a <strong>Whiteboard</strong> widget on
screen yet -- settings below won't show up anywhere until one is added.</div>
{% endif %}
<div class="layout">
+7 -2
View File
@@ -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",
+13 -1
View File
@@ -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
+7 -1
View File
@@ -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"])
+5 -1
View File
@@ -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"])
+177
View File
@@ -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)
+121
View File
@@ -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
+81 -22
View File
@@ -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 ---
@@ -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 = []
+25
View File
@@ -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