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"}