Widget system Phase 4b: per-widget gear-icon config dialogs
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user