Widget system Phase 4b: per-widget gear-icon config dialogs
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s

Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
This commit is contained in:
2026-07-24 14:31:24 -04:00
parent 63751a79ad
commit a33a3a71e4
35 changed files with 2335 additions and 1912 deletions
+659 -7
View File
@@ -1,33 +1,113 @@
"""CRUD + grid placement for a frame's widgets (see models.Widget) --
backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
Every mutation re-validates bounds/minimum footprint/no-overlap
"""Everything scoped to one specific widget rather than "the frame":
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
plus every setting/action that used to assume a frame had at most one
widget of a given type -- photo queue, calendar inclusion/color/tasks,
whiteboard source, and their preview endpoints. Split out of
api_frames.py (which keeps frame-wide settings: orientation, quiet
hours, palette, firmware, stats) once a frame could hold more than one
widget of the same type, at which point "the frame's calendar settings"
stopped meaning anything unambiguous.
Placement mutations re-validate bounds/minimum footprint/no-overlap
server-side regardless of what the client already checked -- the
client's own checks are UX, not the source of truth (this project's
usual posture, e.g. api_frames.py's own field clamps)."""
from __future__ import annotations
import io
import time
from fastapi import APIRouter, Depends, HTTPException, Request
import httpx
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import Response
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import grid
from .. import calendar_render, grid, 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 ..models import Frame, WIDGET_CONFIG_MODELS, Widget
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import DEFAULT_DISPLAY_MODE, DISPLAY_MODES, render_preview_png
from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
)
from ..widgets import WIDGET_TYPES
from .common import (
calendar_sources_for_widget,
fetch_source_and_faces,
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,
valid_http_url,
webdav_creds_for,
)
router = APIRouter()
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
def _widget_dict(w: Widget) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
def require_widget_view(
widget_id: int, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
) -> tuple[Frame, Widget]:
"""View-only widget dependency -- same 404-not-403 posture as
require_frame_view for a widget id that doesn't belong to this
frame (or doesn't exist at all)."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
return frame, widget
def require_widget_control(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
) -> tuple[Frame, Widget]:
"""Same as require_widget_view, but behind the frame's "take control"
soft lock -- for endpoints that mutate the widget's own settings."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
return frame, widget
def _require_widget_type(widget: Widget, expected: str) -> None:
if widget.widget_type != expected:
raise HTTPException(400, f"This widget is a {widget.widget_type} widget, not {expected}")
def _photo_config_or_400(db: Session, frame: Frame, widget: Widget) -> PhotoWidgetConfig:
"""Same 400 shape routers/common.py's photo_widget_config_or_404 uses
for a frame with no configured photo widget at all, here for a widget
we already know is a photos widget -- Immich creds are frame/owner-
level, album_id is this widget's own."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
pcfg = db.get(PhotoWidgetConfig, widget.id)
if not pcfg.album_id:
raise HTTPException(400, "No album configured yet")
return pcfg
@router.get("/api/frames/{frame_id}/widgets")
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
user = require_user_api(request, db)
@@ -140,3 +220,575 @@ def api_widget_delete(
db.delete(widget)
db.commit()
return {"status": "deleted"}
# --- Per-widget-type config save (the gear-icon dialog's Save button) --------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
def api_widget_config_save(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
# photos
album_id: str | None = Form(None),
order: str | None = Form(None),
display_mode: str | None = Form(None),
queue_target_len: int | None = Form(None),
# calendar
calendar_view: str | None = Form(None),
calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None),
calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | None = Form(None),
calendar_tasks_enabled: bool | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
widget instead of "the frame's widget of this type". Fields that
don't apply to this widget's own widget_type are simply ignored,
same posture as an unrecognized form field always had here."""
frame, widget = frame_widget
if widget.widget_type == "photos":
with widget_locked(db, frame.id, 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))
elif widget.widget_type == "calendar":
with widget_locked(db, frame.id, 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
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
# --- Photos: queue/thumbnail/preview ------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
def api_widget_queue(
request: Request, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "photos")
user = require_user_api(request, db)
pcfg = _photo_config_or_400(db, frame, widget)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, 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_asset_id = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue)
controller_id = locked_frame.controlled_by_user_id
controller = (
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
if locked_frame.controlled_by else None
)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/widgets/{widget.id}/thumbnail/{asset_id}"}
return {
"current": entry(current_asset_id) if current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in queue],
"control": {"controller": controller, "you": controller_id == user.id},
}
class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/reorder")
def api_widget_queue_reorder(
body: QueueReorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
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."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
with widget_locked(db, frame.id, 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)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/promote")
def api_widget_queue_promote(
body: QueuePromoteRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Moves a single photo to the front of the queue -- "Show next"."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
with widget_locked(db, frame.id, 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]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/remove")
def api_widget_queue_remove(
body: QueueRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Permanently removes a photo from this widget's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
pcfg = _photo_config_or_400(db, frame, widget)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, 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}/widgets/{widget_id}/thumbnail/{asset_id}")
def api_widget_thumbnail(
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Scoped to what this widget 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 this
widget's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
pcfg = db.get(PhotoWidgetConfig, widget.id)
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:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
def _current_asset_id(db: Session, frame: Frame, widget: Widget) -> tuple[str, PhotoWidgetConfig]:
"""Same idempotent get_current() dance the queue endpoint uses --
picks a current photo if none is set yet, otherwise just reads it,
never advances early."""
pcfg = _photo_config_or_400(db, frame, widget)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, 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, pcfg
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/original")
def api_widget_preview_original(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the dialog's before/after
comparison."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
asset_id, _ = _current_asset_id(db, frame, widget)
client = immich_client_for(frame)
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
return Response(content=jpeg_bytes, media_type="image/jpeg")
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/rendered")
def api_widget_preview_rendered(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The same photo run through this frame's actual saved rendering
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. display_mode comes from this widget's own
config (palette/color/contrast/dither stay frame-level -- one
physical panel, one set of those)."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
asset_id, pcfg = _current_asset_id(db, frame, widget)
client = immich_client_for(frame)
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=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")
# --- Calendar: inclusion/color/tasks/weather/preview --------------------
class CalendarSelectRequest(BaseModel):
user_id: int
calendar_key: str
calendar_label: str = ""
included: bool
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-select")
def api_widget_calendar_select(
body: CalendarSelectRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
db: Session = Depends(get_db),
):
"""Include/exclude one calendar (calendar_key "ics" or
"caldav:<href>", see FrameCalendar) on this calendar widget.
Deliberately not require_widget_control: adding your own calendar, or
muting anyone's (including your own), is each viewer's own call, not
something a frame's controller manages on someone else's behalf. The
one-sided permission split lives here: turning a calendar ON requires
being its owner (nobody can add someone else's calendar to a shared
frame for them); turning one OFF only requires being linked to the
frame at all, so anyone sharing the display can mute a calendar
they'd rather not see there even if they don't own it."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
user = require_user_api(request, db)
if body.included and body.user_id != user.id:
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.widget_id == widget.id,
FrameCalendar.user_id == body.user_id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
if not body.included:
raise HTTPException(404, "Not currently included on this widget")
row = FrameCalendar(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
db.add(row)
row.included = body.included
if body.calendar_label:
row.calendar_label = body.calendar_label
# Force this widget's merged cache to pick up the change promptly
# rather than waiting out the throttle.
db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.included}
class CalendarColorRequest(BaseModel):
calendar_key: str
color_index: int | None # None clears the pin, reverting to auto-cycle
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-color")
def api_widget_calendar_color(
body: CalendarColorRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Pins a specific panel color to one of your own included calendars
(models.FrameCalendar.color_index) -- always owner-only, unlike
calendar-select's included=False, since recoloring someone else's
calendar isn't the same kind of "I'd rather not see this" veto as
muting it. None clears the pin, reverting calendar_render.py to its
old auto-cycle-by-owner-name behavior for this calendar."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
user = require_user_api(request, db)
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.widget_id == widget.id,
FrameCalendar.user_id == user.id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
raise HTTPException(404, "Not included on this widget")
row.color_index = body.color_index
db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "color_index": row.color_index}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/calendar")
def api_widget_preview_calendar(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_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."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
if not calendar_sources_for_widget(db, widget):
raise HTTPException(400, "No calendars included on this widget yet")
ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (ccfg.view == "week" and ccfg.tasks_enabled) else None
)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, 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")
class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source")
def api_widget_tasks_source(
body: TasksSourceRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Points this widget's week-view task list at one of the calling
user's own CalDAV calendars -- same owner-controls-their-own-data
permission split as calendar-select's included=True, since this is
volunteering personal calendar data, not a display setting a
controller should get to pick on someone else's behalf. None clears
the source; clearing (unlike setting) isn't ownership-gated -- like
muting a shared calendar, anyone linked to the frame can turn off a
task list they'd rather not see, but only its owner can point the
widget at one of their calendars to begin with."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.calendar_key is None:
cfg.tasks_user_id = None
cfg.tasks_calendar_key = None
cfg.tasks_cached = None
else:
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}
class WeatherCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/add")
def api_widget_weather_city_add(
body: WeatherCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Geocodes a free-text city name (e.g. "Portland, OR") and adds it to
this widget's weather strip -- a widget-wide display setting (like
calendar_view), not personal data, so this is gated the same way as
the config-save endpoint rather than the calendar-select owner/mute
split."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, 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 widget's list")
cities.append(city)
cfg.weather_cities = cities
cfg.weather_checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/remove")
def api_widget_weather_city_remove(
body: WeatherCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
with widget_locked(db, frame.id, 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"}
# --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel):
url: str | None # None clears the source
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-source")
def api_widget_whiteboard_source(
body: WhiteboardSourceRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Points this widget at one of the calling user's own WebDAV (or
reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
same owner-controls-their-own-data permission split as
api_widget_tasks_source: only the account owner can set the widget to
use it, but anyone linked to the frame can clear it, same as muting a
shared calendar."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.url is 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.user_id = user.id
cfg.url = stripped
cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "url": body.url}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-browse")
def api_widget_whiteboard_browse(
request: Request, url: str | None = None,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""One level of a WebDAV directory listing, using the calling user's
own credentials (never this widget's saved user_id -- this is "help
me find a file in MY account", same person as whoever would go on to
Save it, before that's even happened) -- powers the file picker in
the whiteboard dialog as an alternative to pasting a URL. Nested
under this widget's own path purely so the dialog's JS can keep using
one shared window.FRAME_API base for every call it makes -- the
lookup itself doesn't touch this (or any) widget's own state. `url`
omitted/None starts from the user's webdav_base_url (see models.py's
User docstring); passing back a previous response's `entries[].url`
(for a folder) descends into it."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
user = require_user_api(request, db)
creds = webdav_creds_for(user)
if creds is None:
raise HTTPException(400, "Set up WebDAV credentials in Settings first")
target = url or user.webdav_base_url
if not target:
raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
if not valid_http_url(target):
raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
try:
entries = webdav_client.list_directory(target, creds[0], creds[1])
except webdav_client.WebDavError as e:
raise HTTPException(502, f"Could not browse: {e}")
base = user.webdav_base_url or target
parent_url = webdav_client.parent_directory_url(base, target)
return {"current_url": target, "parent_url": parent_url, "entries": entries}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/whiteboard")
def api_widget_preview_whiteboard(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch/render cache a live device request would
use, run through the same panel composition/quantization pipeline --
"how it will look on the frame" (dithered, letterboxed), not just the
raw Excalidraw export, same convention as the other preview
endpoints. force=True (the "Refresh now" button, as opposed to just
reopening the dialog) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
wcfg = db.get(WhiteboardWidgetConfig, widget.id)
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget, force=force)
if png_bytes is None:
if not wcfg.url:
raise HTTPException(400, "No whiteboard configured on this widget yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox",
)
return Response(content=png, media_type="image/png")