Widget system Phase 2: full cutover to widget-based rendering
device.py's mode-keyed dispatch is replaced by a real compositor: load a frame's widgets, compute pixel rects via app/grid.py, render each through its widget module, and composite with render_panel. Physical NEXT/BACK buttons now execute each frame's assigned FrameButtonAction rows instead of one hardcoded per-mode action. api_frames.py, manage.py, and common.py's build_manage_content are repointed to read/write the frame's widget config rows instead of the old Frame columns, and every settings page (Photos/Calendar/ Whiteboard tabs) now pre-fills its form from the same widget config the write endpoints actually save to -- previously the read and write sides would have silently diverged. The old mode selector and photo-inlay checkbox are removed along with their now-inert wiring; arbitrary widget placement subsumes what the fixed inlay split did. Ships together with Phase 1 (per-type render/action modules) since splitting the read/write cutover across deploys would have left settings changes with no visible effect.
This commit is contained in:
+206
-164
@@ -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"
|
||||
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 != 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 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:
|
||||
cfg.calendar_week_start = max(0, min(6, calendar_week_start))
|
||||
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 != 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 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:
|
||||
cfg.calendar_week_layout = calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
|
||||
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 != cfg.calendar_week_start_offset:
|
||||
cfg.calendar_browse_offset = 0
|
||||
cfg.calendar_week_start_offset = new_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:
|
||||
cfg.calendar_weather_enabled = calendar_weather_enabled
|
||||
ccfg.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_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:
|
||||
cfg.calendar_tasks_enabled = calendar_tasks_enabled
|
||||
cfg.stats_config_saves += 1
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
+148
-210
@@ -16,11 +16,21 @@ from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
|
||||
from ..db import frame_locked, widget_locked
|
||||
from ..image_pipeline import render_frame
|
||||
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
|
||||
from ..db import widget_locked
|
||||
from ..image_pipeline import logical_render_size
|
||||
from ..immich_client import ImmichClient
|
||||
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, User, Widget, WhiteboardWidgetConfig
|
||||
from ..models import (
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
PhotoWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,14 +83,6 @@ def immich_client_for(frame: Frame) -> ImmichClient:
|
||||
return ImmichClient(url, key)
|
||||
|
||||
|
||||
def require_configured(frame: Frame) -> None:
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
if not frame.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
|
||||
|
||||
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
|
||||
try:
|
||||
assets = client.list_album_assets(album_id)
|
||||
@@ -118,14 +120,6 @@ def fetch_source_and_faces(
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
|
||||
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage)
|
||||
|
||||
|
||||
def _avg_wake_interval_s(frame: Frame) -> float:
|
||||
"""Average wall-clock seconds between wakes: refresh_interval_s
|
||||
scaled up for however much of each day quiet hours removes from the
|
||||
@@ -355,30 +349,63 @@ def _format_taken_at(exif: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _manage_content_asset_id(frame: Frame) -> str | None:
|
||||
"""Whether frame.current_asset_id refers to a photo actually visible
|
||||
right now, for whichever mode is active -- always true in photos
|
||||
mode; only true in calendar mode when that view's photo inlay is on
|
||||
(otherwise current_asset_id could be stale, left over from whenever
|
||||
photos mode last ran, and showing its location/date/share info on a
|
||||
manage overlay over a view with no visible photo at all would be
|
||||
actively misleading, not just unhelpful)."""
|
||||
relevant = frame.mode != "calendar" or frame.calendar_photo_inlay
|
||||
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
||||
def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
|
||||
"""The frame's first widget of this type, by placement order. Until
|
||||
the placement UI (a later phase) ships, every frame has at most one
|
||||
widget per type -- the auto-migrated default -- so callers needing
|
||||
"the photo widget" / "the calendar widget" / "the whiteboard widget"
|
||||
for what's still effectively a single-widget-per-type frame use this
|
||||
rather than querying Widget directly. None if the frame has no widget
|
||||
of this type."""
|
||||
return db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type)
|
||||
.order_by(Widget.sort_order)
|
||||
).first()
|
||||
|
||||
|
||||
def _manage_content_region(frame: Frame) -> tuple[int, int, int, int] | None:
|
||||
"""Where the photo behind _manage_content_asset_id actually landed in
|
||||
the logical canvas -- None (the whole canvas) in photos mode, or
|
||||
calendar_render.inlay_region(...) when a calendar view's photo inlay
|
||||
is what's showing. Needed so face labels (and, if ever added, other
|
||||
photo-relative overlay positioning) land on the actual inlaid photo
|
||||
instead of where a full-panel photo would have been."""
|
||||
if frame.mode == "calendar" and frame.calendar_photo_inlay:
|
||||
from ..calendar_render import inlay_region
|
||||
def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
|
||||
"""The frame's photo widget + its config, or a 400 if Immich creds or
|
||||
an album aren't set up yet. Immich creds are frame/owner-level, but
|
||||
album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
|
||||
manage.py, whose photo-related endpoints both need exactly this."""
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
widget = widget_of_type(db, frame, "photos")
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None
|
||||
if widget is None or not cfg.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
return widget, cfg
|
||||
|
||||
return inlay_region(frame.orientation)
|
||||
|
||||
def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]:
|
||||
return db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
|
||||
.order_by(Widget.sort_order)
|
||||
).all()
|
||||
|
||||
|
||||
def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None:
|
||||
"""The one photo widget the manage overlay's location/date/share-link
|
||||
boxes show info for -- unlike face labels (which generalize to every
|
||||
photo widget on screen, see build_manage_content), there's only one
|
||||
of each of these fixed panel corners to go around, so with more than
|
||||
one photo widget some single one has to be picked. Resolution rule:
|
||||
whichever photo widget the NEXT button's first assigned action
|
||||
targets, falling back to the first photo widget by placement order
|
||||
if none is button-assigned."""
|
||||
if not photo_widgets:
|
||||
return None
|
||||
next_actions = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next")
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
photo_widget_ids = {w.id for w in photo_widgets}
|
||||
for action in next_actions:
|
||||
if action.widget_id in photo_widget_ids:
|
||||
return next(w for w in photo_widgets if w.id == action.widget_id)
|
||||
return photo_widgets[0]
|
||||
|
||||
|
||||
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
@@ -387,47 +414,66 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
/frame/face-labels, both removed -- see the module docstring in
|
||||
manage_overlay.py) are now just internal calls made here, once,
|
||||
server-side, since compositing itself also moved server-side.
|
||||
management_url and battery_percent always apply; location/date/
|
||||
share-URL/face-labels only when there's a real current photo (see
|
||||
_manage_content_asset_id) -- absent otherwise, which
|
||||
manage_overlay.compose() already treats as "skip that region",
|
||||
exactly the graceful-degradation behavior the old firmware-fetched
|
||||
version had."""
|
||||
management_url and battery_percent always apply. location/date/
|
||||
share-URL come from one "primary" photo widget (see
|
||||
_primary_photo_widget -- there's only one of each of those fixed
|
||||
panel corners, so with more than one photo widget on screen some
|
||||
single one has to be picked); face labels generalize more simply,
|
||||
since manage_overlay.compose() already takes a flat list and draws
|
||||
each one independently -- every photo widget's own named faces get
|
||||
concatenated in, each positioned within that widget's own region
|
||||
(see face_labels.compute_face_labels' region param) rather than as
|
||||
if a photo filled the whole panel."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
content: dict = {
|
||||
"management_url": f"{base}/m/{frame.manage_token}",
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
asset_id = _manage_content_asset_id(frame)
|
||||
if not asset_id:
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
if not photo_widgets:
|
||||
return content
|
||||
|
||||
primary = _primary_photo_widget(db, frame, photo_widgets)
|
||||
primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
|
||||
if primary_cfg and primary_cfg.current_asset_id:
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
asset = client.get_asset(primary_cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
|
||||
return content
|
||||
|
||||
logger.warning(
|
||||
"Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e
|
||||
)
|
||||
else:
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{asset_id}"
|
||||
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
|
||||
|
||||
if any((face.get("person") or {}).get("name") for face in faces):
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
face_labels: list[dict] = []
|
||||
for widget in photo_widgets:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.current_asset_id:
|
||||
continue
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
|
||||
faces = client.get_asset_faces(cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e)
|
||||
continue
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
continue # no Immich-identified person on this widget's current photo -- nothing to label
|
||||
|
||||
from ..face_labels import compute_face_labels
|
||||
|
||||
content["face_labels"] = compute_face_labels(
|
||||
preview_bytes, faces, frame.display_mode, frame.orientation,
|
||||
region=_manage_content_region(frame),
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h))
|
||||
face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation,
|
||||
region=region))
|
||||
|
||||
if face_labels:
|
||||
content["face_labels"] = face_labels
|
||||
return content
|
||||
|
||||
|
||||
@@ -459,45 +505,21 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.
|
||||
return sources
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
||||
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
|
||||
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- same shape as the Gitea release-check throttle in api_frames.py's
|
||||
api_firmware_check. One shared cache for the whole merged result
|
||||
(every included user's events together), not per-user -- ICS feeds
|
||||
are small and this refetches at most every ~20 minutes regardless of
|
||||
how many are included, so per-user cache columns would add
|
||||
bookkeeping for a marginal benefit."""
|
||||
now = time.time()
|
||||
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return frame.calendar_cached_events, frame.calendar_fetch_summary
|
||||
|
||||
sources = calendar_sources_for_frame(db, frame)
|
||||
today = quiet_hours.local_date(frame)
|
||||
events, summary = calendar_feed.merge_events(
|
||||
sources,
|
||||
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
|
||||
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
|
||||
)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_cached_events = events
|
||||
locked.calendar_fetch_summary = summary
|
||||
locked.calendar_checked_at = now
|
||||
return events, summary
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
|
||||
"""Widget-scoped twin of get_or_refresh_calendar_events above,
|
||||
reading/writing CalendarWidgetConfig instead of Frame columns
|
||||
directly -- same throttle/caching rationale, unchanged. Not yet used
|
||||
by any router (see app/widgets/calendar.py, which this backs) --
|
||||
device.py's actual dispatch still calls the Frame-scoped version
|
||||
above until the widget-system cutover lands; both exist side by side
|
||||
until then. calendar_sources_for_frame stays frame_id-scoped (see
|
||||
FrameCalendar's own docstring) until a later phase re-keys it to
|
||||
widget_id, so every calendar widget on a frame currently shares the
|
||||
same "included calendars" set -- not a real limitation yet since
|
||||
nothing supports more than one calendar widget per frame end to end
|
||||
until that phase lands."""
|
||||
api_firmware_check -- reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py, which this backs). One shared cache for the
|
||||
whole merged result (every included user's events together), not
|
||||
per-user -- ICS feeds are small and this refetches at most every ~20
|
||||
minutes regardless of how many are included, so per-user cache
|
||||
columns would add bookkeeping for a marginal benefit.
|
||||
calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's
|
||||
own docstring) until a later phase re-keys it to widget_id, so every
|
||||
calendar widget on a frame currently shares the same "included
|
||||
calendars" set -- not a real limitation yet since nothing supports
|
||||
more than one calendar widget per frame end to end until that phase
|
||||
lands."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
now = time.time()
|
||||
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
@@ -517,44 +539,15 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget:
|
||||
return events, summary
|
||||
|
||||
|
||||
def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
|
||||
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
|
||||
much longer than calendar_feed's -- weather doesn't need to be
|
||||
that fresh). [] if weather's off or no cities are configured. A city
|
||||
whose refetch fails keeps its last-known days rather than going
|
||||
blank for one bad cycle -- calendar_render.py would otherwise show a
|
||||
real city as having no forecast at all just because one refresh hit
|
||||
a network hiccup."""
|
||||
if not frame.calendar_weather_enabled or not frame.calendar_weather_cities:
|
||||
return []
|
||||
now = time.time()
|
||||
if (frame.calendar_weather_cached is not None
|
||||
and now - frame.calendar_weather_checked_at < weather.CHECK_INTERVAL_S):
|
||||
return frame.calendar_weather_cached
|
||||
|
||||
previous_days = {c["label"]: c.get("days", {}) for c in (frame.calendar_weather_cached or [])}
|
||||
result = []
|
||||
for city in frame.calendar_weather_cities:
|
||||
try:
|
||||
days = weather.fetch_daily_forecast(
|
||||
city["latitude"], city["longitude"], frame.calendar_weather_units
|
||||
)
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||
days = previous_days.get(city["label"], {})
|
||||
result.append({"label": city["label"], "days": days})
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_weather_cached = result
|
||||
locked.calendar_weather_checked_at = now
|
||||
return result
|
||||
|
||||
|
||||
def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
||||
"""Widget-scoped twin of get_or_refresh_weather above -- same
|
||||
throttle/caching rationale, unchanged. Not yet used by any router
|
||||
(see app/widgets/calendar.py) -- both versions coexist until the
|
||||
widget-system cutover lands."""
|
||||
"""Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
|
||||
longer than calendar_feed's -- weather doesn't need to be that
|
||||
fresh), reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py). [] if weather's off or no cities are
|
||||
configured. A city whose refetch fails keeps its last-known days
|
||||
rather than going blank for one bad cycle -- calendar_render.py
|
||||
would otherwise show a real city as having no forecast at all just
|
||||
because one refresh hit a network hiccup."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
if not cfg.weather_enabled or not cfg.weather_cities:
|
||||
return []
|
||||
@@ -578,42 +571,14 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
||||
return result
|
||||
|
||||
|
||||
def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
|
||||
"""Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S,
|
||||
same cadence as event merging) -- [] if tasks are off, no source is
|
||||
set, or the source user's CalDAV credentials/calendar_key have gone
|
||||
missing (e.g. they unlinked their account). A refetch failure keeps
|
||||
the last-known list rather than going blank for one bad cycle, same
|
||||
reasoning as get_or_refresh_weather."""
|
||||
if not frame.calendar_tasks_enabled or not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
|
||||
return []
|
||||
now = time.time()
|
||||
if (frame.calendar_tasks_cached is not None
|
||||
and now - frame.calendar_tasks_checked_at < calendar_feed.CHECK_INTERVAL_S):
|
||||
return frame.calendar_tasks_cached
|
||||
|
||||
user = db.get(User, frame.calendar_tasks_user_id)
|
||||
key = frame.calendar_tasks_calendar_key
|
||||
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
|
||||
return frame.calendar_tasks_cached or []
|
||||
href = key[len("caldav:"):]
|
||||
try:
|
||||
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
|
||||
except caldav_client.CalDavError as e:
|
||||
logger.warning("Could not refresh tasks for frame %d: %s", frame.id, e)
|
||||
return frame.calendar_tasks_cached or []
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_tasks_cached = tasks
|
||||
locked.calendar_tasks_checked_at = now
|
||||
return tasks
|
||||
|
||||
|
||||
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
||||
"""Widget-scoped twin of get_or_refresh_tasks above -- same throttle/
|
||||
caching rationale, unchanged. Not yet used by any router (see
|
||||
app/widgets/calendar.py) -- both versions coexist until the
|
||||
widget-system cutover lands."""
|
||||
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
|
||||
cadence as event merging), reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py). [] if tasks are off, no source is set, or
|
||||
the source user's CalDAV credentials/calendar_key have gone missing
|
||||
(e.g. they unlinked their account). A refetch failure keeps the
|
||||
last-known list rather than going blank for one bad cycle, same
|
||||
reasoning as get_or_refresh_weather_for_widget."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
|
||||
return []
|
||||
@@ -653,48 +618,21 @@ def webdav_creds_for(user: User) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) -> bytes | None:
|
||||
"""Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- None if no whiteboard source is configured, credentials are
|
||||
missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
|
||||
or the most recent fetch/render failed and nothing was ever cached
|
||||
yet. A failure after a previous success keeps showing the last
|
||||
good render rather than going blank for one bad refresh cycle, same
|
||||
reasoning as get_or_refresh_weather/get_or_refresh_tasks. force=True
|
||||
(the web UI's "Refresh now" button) skips the throttle entirely --
|
||||
unlike a device's normal wake, a person clicking a button means do
|
||||
it right now, not eventually once the cache goes stale."""
|
||||
if not frame.whiteboard_url or not frame.whiteboard_user_id:
|
||||
return None
|
||||
now = time.time()
|
||||
if (not force and frame.whiteboard_cached_image is not None
|
||||
and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
creds = webdav_creds_for(user) if user else None
|
||||
if creds is None:
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
try:
|
||||
png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1])
|
||||
except whiteboard.WhiteboardRenderError as e:
|
||||
logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e)
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_cached_image = png
|
||||
locked.whiteboard_checked_at = now
|
||||
return png
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard_for_widget(
|
||||
db: Session, frame: Frame, widget: Widget, force: bool = False
|
||||
) -> bytes | None:
|
||||
"""Widget-scoped twin of get_or_refresh_whiteboard above -- same
|
||||
throttle/caching/force rationale, unchanged. Not yet used by any
|
||||
router (see app/widgets/whiteboard.py) -- both versions coexist
|
||||
until the widget-system cutover lands."""
|
||||
"""Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
|
||||
writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
|
||||
None if no whiteboard source is configured, credentials are missing
|
||||
(e.g. the owning user unlinked their WebDAV/CalDAV account), or the
|
||||
most recent fetch/render failed and nothing was ever cached yet. A
|
||||
failure after a previous success keeps showing the last good render
|
||||
rather than going blank for one bad refresh cycle, same reasoning as
|
||||
get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget.
|
||||
force=True (the web UI's "Refresh now" button) skips the throttle
|
||||
entirely -- unlike a device's normal wake, a person clicking a
|
||||
button means do it right now, not eventually once the cache goes
|
||||
stale."""
|
||||
cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||
if not cfg.url or not cfg.user_id:
|
||||
return None
|
||||
|
||||
+125
-217
@@ -12,39 +12,32 @@ see manage_overlay.py and common.build_manage_content)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, mail, photo_queue, quiet_hours
|
||||
from .. import grid, mail, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_frame, render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
RECHARGE_LOOKBACK,
|
||||
build_manage_content,
|
||||
get_or_refresh_calendar_events,
|
||||
get_or_refresh_tasks,
|
||||
get_or_refresh_weather,
|
||||
get_or_refresh_whiteboard,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
render_asset,
|
||||
require_configured,
|
||||
photo_widgets_for_frame,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,11 +46,11 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
|
||||
"""What an unclaimed or not-yet-configured frame displays instead of a
|
||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
||||
it as a perfectly normal image and never error-loops. The URLs are
|
||||
built from the request's own base URL: whatever address the device
|
||||
reached us at is by definition an address that works on this
|
||||
"""What an unclaimed or widget-less frame displays instead of real
|
||||
content -- instructions with a QR, rendered at 200 so the device
|
||||
treats it as a perfectly normal image and never error-loops. The
|
||||
URLs are built from the request's own base URL: whatever address the
|
||||
device reached us at is by definition an address that works on this
|
||||
network."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
if frame.owner_user_id is None and frame.device_id:
|
||||
@@ -77,7 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
manage=manage,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Pick an album for this frame:", base],
|
||||
["Almost there!", "Add a widget for this frame at", base],
|
||||
qr_url=base,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
@@ -85,189 +78,92 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
)
|
||||
|
||||
|
||||
def _frame_configured(frame: Frame) -> bool:
|
||||
url, key = immich_creds(frame)
|
||||
return bool(url and key and frame.album_id)
|
||||
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool) -> bytes:
|
||||
"""The widget-system compositor: renders every widget on this frame
|
||||
into its own region (see app/grid.py for grid-cell -> pixel math) and
|
||||
hands the results to image_pipeline.render_panel for the single
|
||||
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
||||
per-mode RENDERERS dict -- a frame can now show several widgets at
|
||||
once instead of exactly one mode owning the whole panel."""
|
||||
all_widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
regions = []
|
||||
for widget in all_widgets:
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None:
|
||||
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||
px, py, pw, ph = grid.cell_to_pixels(
|
||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
||||
)
|
||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||
regions.append(((px, py, pw, ph), img))
|
||||
return render_panel(
|
||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage,
|
||||
)
|
||||
|
||||
|
||||
# --- photos mode ---
|
||||
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
if not _frame_configured(frame):
|
||||
"""The top-level "what does this frame show right now" entry point.
|
||||
An unclaimed frame or one with no widgets yet gets the setup
|
||||
placeholder (needs `request` for its QR URLs -- only available on the
|
||||
normal-wake path where a real request is on hand, never on an
|
||||
advance/back button press); otherwise every widget on it gets
|
||||
composited via _render_widgets. Individual widgets that are
|
||||
themselves unconfigured show their own small placeholder within
|
||||
their own region (see app/widgets/*.py) rather than blanking the
|
||||
whole panel -- a partially-set-up multi-widget frame still shows
|
||||
whatever IS configured."""
|
||||
has_widgets = frame.owner_user_id is not None and (
|
||||
db.scalars(select(Widget.id).where(Widget.frame_id == frame.id).limit(1)).first() is not None
|
||||
)
|
||||
if not has_widgets:
|
||||
if request is None:
|
||||
return render_placeholder(
|
||||
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage
|
||||
)
|
||||
return _setup_placeholder(frame, request, manage=manage)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
return _render_widgets(db, frame, manage, is_normal_wake)
|
||||
|
||||
|
||||
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.advance_forced(locked, assets, locked)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.back_forced(locked, assets, locked)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
# --- calendar mode ---
|
||||
|
||||
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
from .common import calendar_sources_for_frame
|
||||
|
||||
if not calendar_sources_for_frame(db, frame):
|
||||
return render_placeholder(
|
||||
["This frame's calendar isn't set up yet",
|
||||
"Add a calendar in Settings, then include it on",
|
||||
"this frame's Configuration -> Calendar card."],
|
||||
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
||||
)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
if is_normal_wake and locked.calendar_browse_offset != 0:
|
||||
locked.calendar_browse_offset = 0
|
||||
browse_offset = locked.calendar_browse_offset
|
||||
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
week_start = locked.calendar_week_start
|
||||
week_days = locked.calendar_week_days
|
||||
week_layout = locked.calendar_week_layout
|
||||
week_start_offset = locked.calendar_week_start_offset
|
||||
inlay_wanted = locked.calendar_photo_inlay
|
||||
|
||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||
weather_cities = get_or_refresh_weather(db, frame)
|
||||
# Only ever shown on the week view (see calendar_render._build_week) --
|
||||
# gated here too so a disabled/other-view frame never pays for the
|
||||
# fetch, and so None (not just an empty list) reaches render_calendar
|
||||
# to mean "no tasks slot at all", distinct from "slot reserved but
|
||||
# nothing outstanding right now".
|
||||
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
|
||||
|
||||
photo_inlay = None
|
||||
if inlay_wanted and _frame_configured(frame):
|
||||
def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
|
||||
"""Executes every (widget, action) binding assigned to this physical
|
||||
button, in order -- see models.FrameButtonAction and the button-
|
||||
assignment UI (a later phase). Each action runs to completion (its
|
||||
own widget_locked span) before the next one starts -- never nested,
|
||||
since db.widget_locked's underlying lock isn't reentrant (see its own
|
||||
docstring) -- a button assigned several actions would deadlock
|
||||
instantly if this looped any other way. One action failing
|
||||
unexpectedly doesn't block the others, or the eventual re-render,
|
||||
from happening -- the user pressed a physical button and expects
|
||||
*something* to happen even if one of several assigned widgets is
|
||||
having a bad moment."""
|
||||
actions = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
for action_row in actions:
|
||||
widget = db.get(Widget, action_row.widget_id)
|
||||
if widget is None:
|
||||
continue
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
action_fn = module.ACTIONS.get(action_row.action) if module else None
|
||||
if action_fn is None:
|
||||
continue
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
if asset_id:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
|
||||
except HTTPException:
|
||||
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
|
||||
|
||||
return calendar_render.render_calendar(
|
||||
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
|
||||
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
|
||||
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
|
||||
week_days=week_days, week_layout=week_layout, tasks=tasks, week_start_offset=week_start_offset,
|
||||
action_fn(db, frame, widget)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id
|
||||
)
|
||||
|
||||
|
||||
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""NEXT in calendar mode: moves the displayed period forward one step
|
||||
(day for agenda, week for week view, month for month view) from
|
||||
wherever it currently is -- not from "today" -- so repeated presses
|
||||
walk further forward. See Frame.calendar_browse_offset."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_browse_offset += 1
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_browse_offset -= 1
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
# --- whiteboard mode ---
|
||||
|
||||
def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
"""Fetches (throttled, see get_or_refresh_whiteboard) and renders the
|
||||
frame's configured .whiteboard file. The rendered PNG is treated
|
||||
exactly like a photo from here on -- run through the same
|
||||
render_frame composition/quantization pipeline as photos mode,
|
||||
letterboxed (never cropped: unlike a photo, losing part of a
|
||||
whiteboard to a crop loses actual content, not just some background)
|
||||
-- rather than a second parallel image pipeline just for this mode."""
|
||||
png_bytes = get_or_refresh_whiteboard(db, frame)
|
||||
if png_bytes is None:
|
||||
return render_placeholder(
|
||||
["This frame's whiteboard isn't set up yet",
|
||||
"Add a WebDAV/Nextcloud whiteboard file URL on",
|
||||
"this frame's Whiteboard tab."],
|
||||
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
||||
)
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
return render_frame(
|
||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode="letterbox", manage=manage,
|
||||
)
|
||||
|
||||
|
||||
def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""NEXT in whiteboard mode: there's no "next" concept for a single
|
||||
static board, so this instead forces an immediate re-fetch/re-render
|
||||
bypassing the throttle -- a "check now" button for "someone just
|
||||
updated the board, show it right away" rather than waiting out
|
||||
calendar_feed.CHECK_INTERVAL_S."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_checked_at = 0.0
|
||||
return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""Same "check now" behavior as _advance_whiteboard_mode -- there's
|
||||
no separate "back" concept for a single static board either."""
|
||||
return _advance_whiteboard_mode(db, frame, manage)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"photos": _render_photos_mode,
|
||||
"calendar": _render_calendar_mode,
|
||||
"whiteboard": _render_whiteboard_mode,
|
||||
}
|
||||
ADVANCE_RENDERERS = {
|
||||
"photos": _advance_photos_mode,
|
||||
"calendar": _advance_calendar_mode,
|
||||
"whiteboard": _advance_whiteboard_mode,
|
||||
}
|
||||
BACK_RENDERERS = {
|
||||
"photos": _back_photos_mode,
|
||||
"calendar": _back_calendar_mode,
|
||||
"whiteboard": _back_whiteboard_mode,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/frame/config")
|
||||
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Device-facing settings, polled by the frame alongside its
|
||||
@@ -311,42 +207,47 @@ def _manage_flag(request: Request) -> bool:
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Returns the frame's current image. For photos mode: idempotent --
|
||||
only actually advances to the next photo once refresh_interval_s has
|
||||
elapsed since the current one was set (see app/photo_queue.py) --
|
||||
safe to call as often as the device wants, including after an
|
||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
||||
an error) so a fresh device never error-loops.
|
||||
"""Returns the frame's current image -- every widget on the frame
|
||||
composited into one panel (see _render_widgets). Each widget's own
|
||||
render is idempotent in whatever way makes sense for its type (e.g.
|
||||
a photo widget only actually advances once its own refresh interval
|
||||
has elapsed, see app/photo_queue.py) -- safe to call as often as the
|
||||
device wants, including after an unplanned reboot, without skipping
|
||||
ahead. An unclaimed frame or one with no widgets yet gets a rendered
|
||||
instruction placeholder (200, not an error) so a fresh device never
|
||||
error-loops.
|
||||
|
||||
?manage=1 (the manage button) composites the manage overlay onto
|
||||
whatever this would have returned anyway -- see build_manage_content.
|
||||
For calendar mode, this is also the "normal wake" that resets
|
||||
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
|
||||
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
||||
This is also the "normal wake" that resets any calendar widget's
|
||||
browse position back to today (see app/widgets/calendar.py)."""
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = renderer(db, frame, request, manage, True)
|
||||
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/advance")
|
||||
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Forces an immediate move forward -- the next photo in photos mode,
|
||||
or the next day/week/month in calendar mode -- ignoring
|
||||
refresh_interval_s. Used by the device's next-photo button."""
|
||||
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
|
||||
"""Forces an immediate move forward on whatever widget(s) the NEXT
|
||||
button is assigned to (see models.FrameButtonAction) -- e.g. the next
|
||||
photo for a photo widget, or the next day/week/month for a calendar
|
||||
widget -- then re-renders and returns the whole panel. Used by the
|
||||
device's next-photo button."""
|
||||
_run_button_actions(db, frame, "next")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/back")
|
||||
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""The mirror of /frame/advance -- back a photo in photos mode, back
|
||||
a period in calendar mode. A no-op (still 200, unchanged) if there's
|
||||
nothing to go back to. Used by the device's back-photo button."""
|
||||
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
|
||||
"""The mirror of /frame/advance, for whatever widget(s) the BACK
|
||||
button is assigned to. A no-op (still 200, unchanged) for any widget
|
||||
with nothing to go back to. Used by the device's back-photo button."""
|
||||
_run_button_actions(db, frame, "back")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
class BatteryReport(BaseModel):
|
||||
@@ -441,18 +342,25 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
||||
|
||||
|
||||
@router.get("/frame/share/{asset_id}")
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Creates a 30-minute public Immich share link for asset_id and
|
||||
redirects to it -- what the manage overlay's bottom-left QR code
|
||||
points to. The link is created lazily, when this actually gets hit
|
||||
(i.e. when someone scans it), not when the manage button was
|
||||
pressed, so the 30-minute window starts when it's actually used.
|
||||
Also scoped to the photo currently showing or queued on THIS frame --
|
||||
not any arbitrary Immich asset id -- as a second layer even a leaked
|
||||
token wouldn't bypass."""
|
||||
require_configured(frame)
|
||||
Also scoped to the photo currently showing or queued on one of THIS
|
||||
frame's own photo widgets -- not any arbitrary Immich asset id -- as
|
||||
a second layer even a leaked token wouldn't bypass."""
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
showing_or_queued = any(
|
||||
asset_id == cfg.current_asset_id or asset_id in cfg.queue
|
||||
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
|
||||
)
|
||||
if not showing_or_queued:
|
||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
||||
|
||||
client = immich_client_for(frame)
|
||||
|
||||
@@ -20,9 +20,9 @@ from ..image_pipeline import (
|
||||
PALETTE_LABELS,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import Frame, FrameCalendar, User, UserFrame
|
||||
from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from .common import shell_context
|
||||
from .common import shell_context, widget_of_type
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
@@ -36,15 +36,27 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
|
||||
if frame is None or not can_view_frame(db, user, frame):
|
||||
raise HTTPException(404, "No such frame")
|
||||
ctx = shell_context(request, db, user, active_frame=frame)
|
||||
ctx.update({"frame": frame, "active_tab": tab, **extra})
|
||||
ctx.update({
|
||||
"frame": frame, "active_tab": tab,
|
||||
"has_calendar_widget": widget_of_type(db, frame, "calendar") is not None,
|
||||
"has_whiteboard_widget": widget_of_type(db, frame, "whiteboard") is not None,
|
||||
**extra,
|
||||
})
|
||||
return templates.TemplateResponse(template, ctx)
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
||||
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
frame = db.get(Frame, frame_id)
|
||||
photo_cfg = None
|
||||
if frame is not None:
|
||||
photo_widget = widget_of_type(db, frame, "photos")
|
||||
if photo_widget is not None:
|
||||
photo_cfg = db.get(PhotoWidgetConfig, photo_widget.id)
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_photos.html", "photos",
|
||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
||||
photo_cfg=photo_cfg,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,19 +111,19 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
|
||||
return result
|
||||
|
||||
|
||||
def _tasks_source_info(db: Session, frame: Frame) -> dict | None:
|
||||
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None:
|
||||
"""Whose CalDAV calendar this frame's week-view task list currently
|
||||
pulls from, and its label -- for showing "using <name>'s Chores
|
||||
list" to everyone linked, not just whoever set it. None if no
|
||||
source is configured."""
|
||||
if not frame.calendar_tasks_user_id or not frame.calendar_tasks_calendar_key:
|
||||
if calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
|
||||
return None
|
||||
user = db.get(User, frame.calendar_tasks_user_id)
|
||||
user = db.get(User, calendar_cfg.tasks_user_id)
|
||||
if user is None:
|
||||
return None
|
||||
label = frame.calendar_tasks_calendar_key
|
||||
label = calendar_cfg.tasks_calendar_key
|
||||
for c in (user.calendar_caldav_calendars or []):
|
||||
if f"caldav:{c['href']}" == frame.calendar_tasks_calendar_key:
|
||||
if f"caldav:{c['href']}" == calendar_cfg.tasks_calendar_key:
|
||||
label = c.get("display_name") or label
|
||||
break
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
|
||||
@@ -137,8 +149,13 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
|
||||
viewer = current_user(request, db)
|
||||
frame = db.get(Frame, frame_id)
|
||||
viewer_task_calendars = []
|
||||
calendar_cfg = None
|
||||
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
|
||||
viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")]
|
||||
if frame is not None:
|
||||
calendar_widget = widget_of_type(db, frame, "calendar")
|
||||
if calendar_widget is not None:
|
||||
calendar_cfg = db.get(CalendarWidgetConfig, calendar_widget.id)
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_calendar.html", "calendar",
|
||||
calendar_views=CALENDAR_VIEW_LABELS,
|
||||
@@ -148,20 +165,21 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
palette_to_hex=palette_to_hex,
|
||||
viewer_task_calendars=viewer_task_calendars,
|
||||
tasks_source=_tasks_source_info(db, frame) if frame is not None else None,
|
||||
calendar_cfg=calendar_cfg,
|
||||
tasks_source=_tasks_source_info(db, calendar_cfg),
|
||||
)
|
||||
|
||||
|
||||
def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None:
|
||||
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig | None) -> dict | None:
|
||||
"""Whose account this frame's whiteboard currently fetches with, for
|
||||
showing "using <name>'s account" to everyone linked, not just
|
||||
whoever set it. None if no source is configured."""
|
||||
if not frame.whiteboard_user_id or not frame.whiteboard_url:
|
||||
if whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url:
|
||||
return None
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
user = db.get(User, whiteboard_cfg.user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url}
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
|
||||
@@ -169,13 +187,18 @@ def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends
|
||||
viewer = current_user(request, db)
|
||||
frame = db.get(Frame, frame_id)
|
||||
viewer_has_webdav_creds = False
|
||||
whiteboard_cfg = None
|
||||
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
|
||||
viewer_has_webdav_creds = bool(
|
||||
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
|
||||
)
|
||||
if frame is not None:
|
||||
whiteboard_widget = widget_of_type(db, frame, "whiteboard")
|
||||
if whiteboard_widget is not None:
|
||||
whiteboard_cfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_whiteboard.html", "whiteboard",
|
||||
whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None,
|
||||
whiteboard_source=_whiteboard_source_info(db, whiteboard_cfg),
|
||||
viewer_has_webdav_creds=viewer_has_webdav_creds,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import frame_locked, get_db
|
||||
from ..db import get_db, widget_locked
|
||||
from ..models import Frame
|
||||
from .common import immich_client_for, list_assets, require_configured
|
||||
from .common import immich_client_for, list_assets, photo_widget_config_or_404
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,15 +46,16 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
|
||||
|
||||
@router.get("/api/m/{manage_token}/queue")
|
||||
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
current = cfg.current_asset_id
|
||||
queue = list(cfg.queue)
|
||||
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||
photo_queue.get_current(locked_pcfg, assets, locked_frame,
|
||||
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
||||
photo_queue.sync_queue_length(locked_pcfg, assets)
|
||||
current = locked_pcfg.current_asset_id
|
||||
queue = list(locked_pcfg.queue)
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
||||
@@ -76,7 +77,8 @@ def manage_promote(
|
||||
frame: Frame = Depends(require_manage),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_widget, _ = photo_widget_config_or_404(db, frame)
|
||||
with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
|
||||
@@ -87,29 +89,30 @@ def manage_promote(
|
||||
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Advances the server-side current photo; the panel itself updates
|
||||
on the device's next wake (or its next-photo button)."""
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.advance_forced(cfg, assets, cfg)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||
photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/back")
|
||||
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame.album_id)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.back_forced(cfg, assets, cfg)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||
photo_queue.back_forced(locked_pcfg, assets, locked_frame)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
||||
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
|
||||
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
||||
the manage token must not become a general Immich proxy."""
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
_, pcfg = photo_widget_config_or_404(db, frame)
|
||||
if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
|
||||
raise HTTPException(404, "Not on this frame")
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Calendar tab: view/week-start/photo-inlay settings, per-user opt-in,
|
||||
// and the rendered preview. Extracted from frame_config.js when the
|
||||
// Calendar card became its own tab (window.FRAME_API is set by the
|
||||
// template; checkboxes are always sent explicitly as "true"/"false").
|
||||
// Calendar tab: view/week-start settings, per-user opt-in, and the
|
||||
// rendered preview. Extracted from frame_config.js when the Calendar
|
||||
// card became its own tab (window.FRAME_API is set by the template;
|
||||
// checkboxes are always sent explicitly as "true"/"false").
|
||||
|
||||
// Week-view-only settings (days/layout/start-offset) only matter when
|
||||
// View is actually "Week"; "Week starts on" also matters for Month, so
|
||||
@@ -31,7 +31,6 @@ document.getElementById('calendar-config-form').addEventListener('submit', async
|
||||
calendar_week_days: document.getElementById('calendar_week_days').value,
|
||||
calendar_week_layout: document.getElementById('calendar_week_layout').value,
|
||||
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
|
||||
calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Page-header controls shared by every per-frame page (Photos/
|
||||
// Configuration/Calendar/Stats): the frame-name pencil-edit and the
|
||||
// mode selector, both now living outside the tab structure since they
|
||||
// apply regardless of which tab is open. Depends on window.FRAME_API
|
||||
// (set per-page) and common.js's showStatus/apiError.
|
||||
// Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit,
|
||||
// living outside the tab structure since it applies regardless of which
|
||||
// tab is open. Depends on window.FRAME_API (set per-page) and
|
||||
// common.js's showStatus/apiError.
|
||||
|
||||
(function () {
|
||||
var view = document.getElementById('frame-name-view');
|
||||
@@ -55,31 +55,3 @@
|
||||
if (e.key === 'Escape') closeEdit();
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
var sel = document.getElementById('frame-mode-select');
|
||||
if (!sel || !window.FRAME_API) return;
|
||||
var previous = sel.value;
|
||||
|
||||
sel.addEventListener('change', async function () {
|
||||
var mode = sel.value;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ mode }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
previous = mode;
|
||||
var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
|
||||
showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`);
|
||||
var calTab = document.querySelector('.tabs a[href$="/calendar"]');
|
||||
if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar');
|
||||
var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]');
|
||||
if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard');
|
||||
} catch (e) {
|
||||
sel.value = previous;
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -514,8 +514,6 @@ code {
|
||||
}
|
||||
.frame-name-edit button { margin-top: 0; }
|
||||
|
||||
.frame-mode-select { width: auto; margin-top: 0; padding: 7px 10px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
.control-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -558,9 +556,6 @@ code {
|
||||
.device-status-row { column-gap: 16px; }
|
||||
}
|
||||
|
||||
.mode-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; }
|
||||
.mode-picker-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
||||
.mobile-bar { display: none; }
|
||||
.sidebar-backdrop { display: none; }
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="mode-picker-row">
|
||||
<span class="mode-picker-label">Mode</span>
|
||||
{% include "_frame_mode_select.html" %}
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
<select id="frame-mode-select" class="frame-mode-select" title="Frame mode" aria-label="Frame mode">
|
||||
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
|
||||
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
|
||||
<option value="whiteboard" {% if frame.mode == "whiteboard" %}selected{% endif %}>Whiteboard</option>
|
||||
</select>
|
||||
@@ -2,8 +2,8 @@
|
||||
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
|
||||
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
|
||||
<a href="/frames/{{ frame.id }}/calendar"
|
||||
class="{% if active_tab == 'calendar' %}active{% endif %} {% if frame.mode != 'calendar' %}tab-disabled{% endif %}">Calendar</a>
|
||||
class="{% if active_tab == 'calendar' %}active{% endif %} {% if not has_calendar_widget %}tab-disabled{% endif %}">Calendar</a>
|
||||
<a href="/frames/{{ frame.id }}/whiteboard"
|
||||
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if frame.mode != 'whiteboard' %}tab-disabled{% endif %}">Whiteboard</a>
|
||||
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if not has_whiteboard_widget %}tab-disabled{% endif %}">Whiteboard</a>
|
||||
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||
</nav>
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% block device_status %}{% endblock %}
|
||||
{% block mode_picker %}{% endblock %}
|
||||
{% block tabs %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
@@ -13,10 +12,9 @@
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
{% if frame.mode != 'calendar' %}
|
||||
<div class="info-box">This frame is currently in <strong>Photos</strong> mode --
|
||||
settings below take effect once you switch it to <strong>Calendar</strong> mode
|
||||
using the selector at the top of the page.</div>
|
||||
{% if not has_calendar_widget %}
|
||||
<div class="info-box">This frame doesn't have a <strong>Calendar</strong> widget on
|
||||
screen yet -- settings below won't show up anywhere until one is added.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
@@ -27,7 +25,7 @@
|
||||
<label>View
|
||||
<select id="calendar_view">
|
||||
{% for value, label in calendar_views.items() %}
|
||||
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option>
|
||||
<option value="{{ value }}" {% if calendar_cfg and calendar_cfg.view == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
@@ -35,7 +33,7 @@
|
||||
<label>Week starts on
|
||||
<select id="calendar_week_start">
|
||||
{% for value, label in week_start_labels.items() %}
|
||||
<option value="{{ value }}" {% if frame.calendar_week_start == value %}selected{% endif %}>{{ label }}</option>
|
||||
<option value="{{ value }}" {% if calendar_cfg and calendar_cfg.week_start == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
@@ -43,30 +41,24 @@
|
||||
</div>
|
||||
<div id="calendar-week-days-row">
|
||||
<label>Days to show (Week view)
|
||||
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ frame.calendar_week_days }}">
|
||||
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ calendar_cfg.week_days if calendar_cfg else 7 }}">
|
||||
</label>
|
||||
</div>
|
||||
<div id="calendar-week-layout-row">
|
||||
<label>Week view layout
|
||||
<select id="calendar_week_layout">
|
||||
<option value="horizontal" {% if frame.calendar_week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
|
||||
<option value="vertical" {% if frame.calendar_week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
|
||||
<option value="horizontal" {% if not calendar_cfg or calendar_cfg.week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
|
||||
<option value="vertical" {% if calendar_cfg and calendar_cfg.week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="calendar-week-offset-row">
|
||||
<label>Week view starts (days from today)
|
||||
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ frame.calendar_week_start_offset }}">
|
||||
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ calendar_cfg.week_start_offset if calendar_cfg else 0 }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
|
||||
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
|
||||
</div>
|
||||
<div class="checkbox-row" id="calendar-inlay-row">
|
||||
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
|
||||
<label for="calendar_photo_inlay">Show a photo alongside the calendar</label>
|
||||
</div>
|
||||
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px;">
|
||||
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
@@ -112,8 +104,8 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if frame.calendar_fetch_summary %}
|
||||
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
|
||||
{% if calendar_cfg and calendar_cfg.fetch_summary %}
|
||||
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ calendar_cfg.fetch_summary }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -123,13 +115,13 @@
|
||||
& tomorrow), and Week views -- there's no room for it on Month.</p>
|
||||
<form id="weather-config-form">
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="weather_enabled" {% if frame.calendar_weather_enabled %}checked{% endif %}>
|
||||
<input type="checkbox" id="weather_enabled" {% if calendar_cfg and calendar_cfg.weather_enabled %}checked{% endif %}>
|
||||
<label for="weather_enabled">Show weather</label>
|
||||
</div>
|
||||
<label>Units
|
||||
<select id="weather_units">
|
||||
<option value="fahrenheit" {% if frame.calendar_weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
<option value="celsius" {% if frame.calendar_weather_units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||
<option value="fahrenheit" {% if not calendar_cfg or calendar_cfg.weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
<option value="celsius" {% if calendar_cfg and calendar_cfg.weather_units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
@@ -139,7 +131,7 @@
|
||||
<p class="sub">Every city shows on every day -- add more than one if
|
||||
people split their time between places.</p>
|
||||
<ul class="calendar-user-list" id="weather-city-list">
|
||||
{% for c in frame.calendar_weather_cities or [] %}
|
||||
{% for c in (calendar_cfg.weather_cities if calendar_cfg else []) or [] %}
|
||||
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
|
||||
<span>{{ c.label }}</span>
|
||||
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
|
||||
@@ -159,7 +151,7 @@
|
||||
<p class="sub">Week view only -- takes the place of one day slot
|
||||
instead of adding an extra one.</p>
|
||||
<div class="checkbox-row" id="tasks-enabled-row">
|
||||
<input type="checkbox" id="tasks_enabled" {% if frame.calendar_tasks_enabled %}checked{% endif %}>
|
||||
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg and calendar_cfg.tasks_enabled %}checked{% endif %}>
|
||||
<label for="tasks_enabled">Show a task list</label>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +171,7 @@
|
||||
{% for c in viewer_task_calendars %}
|
||||
<li class="checkbox-row" style="margin-top: 6px;">
|
||||
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
|
||||
{% if tasks_source and tasks_source.user_id == user.id and frame.calendar_tasks_calendar_key == c.key %}checked{% endif %}>
|
||||
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
|
||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
@@ -20,26 +19,26 @@
|
||||
<form id="photos-form">
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
|
||||
{% if photo_cfg and photo_cfg.album_id %}<option value="{{ photo_cfg.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Upcoming photos to show
|
||||
<select id="queue_target_len">
|
||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||||
<option value="{{ n }}" {% if photo_cfg and photo_cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
<option value="sequential" {% if not photo_cfg or photo_cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if photo_cfg and photo_cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Display mode
|
||||
<select id="display_mode">
|
||||
{% for mode, label in display_mode_labels.items() %}
|
||||
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
<option value="{{ mode }}" {% if photo_cfg and photo_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
@@ -13,10 +12,9 @@
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
{% if frame.mode != 'whiteboard' %}
|
||||
<div class="info-box">This frame is currently in <strong>{{ frame.mode|capitalize }}</strong> mode --
|
||||
settings below take effect once you switch it to <strong>Whiteboard</strong> mode
|
||||
using the selector at the top of the page.</div>
|
||||
{% if not has_whiteboard_widget %}
|
||||
<div class="info-box">This frame doesn't have a <strong>Whiteboard</strong> widget on
|
||||
screen yet -- settings below won't show up anywhere until one is added.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
|
||||
@@ -6,14 +6,19 @@ optionally responds to named button actions."
|
||||
|
||||
Each module in this package exposes:
|
||||
|
||||
render(db, frame, widget, target_w, target_h) -> Image.Image
|
||||
render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image
|
||||
An RGB image exactly target_w x target_h, unquantized -- the
|
||||
widget's content composed into its own region. Never returns
|
||||
packed panel bytes or raises for a foreseeable failure (a
|
||||
widget's own fetch hiccup shows a small placeholder instead) --
|
||||
image_pipeline.render_panel composites every widget's own
|
||||
render() result onto one shared canvas and quantizes/packs the
|
||||
whole thing once (see its own docstring).
|
||||
whole thing once (see its own docstring). is_normal_wake
|
||||
distinguishes an ordinary /frame/image GET from a button-
|
||||
triggered re-render -- only app/widgets/calendar.py's render()
|
||||
actually uses it (resetting browse_offset back to "today" on a
|
||||
normal wake), but every module accepts it for one uniform call
|
||||
signature regardless.
|
||||
|
||||
ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
|
||||
Named button actions this widget type supports (e.g. "advance",
|
||||
|
||||
@@ -34,7 +34,19 @@ from ..routers.common import (
|
||||
ACTION_LABELS = {"advance": "Next period", "back": "Previous period"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake=True (an ordinary /frame/image GET, not a button
|
||||
press) resets browse_offset back to "today" if it had drifted --
|
||||
mirrors the old _render_calendar_mode's identical behavior. A button
|
||||
press explicitly moved the browse position on purpose, so it passes
|
||||
is_normal_wake=False to render its own already-updated offset instead
|
||||
of immediately snapping back to 0."""
|
||||
if is_normal_wake:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
if locked_cfg.browse_offset != 0:
|
||||
locked_cfg.browse_offset = 0
|
||||
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
||||
|
||||
@@ -26,7 +26,13 @@ from ._shared import placeholder_image
|
||||
ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused here -- photos mode's advance timing is
|
||||
already fully idempotent via get_current()'s own elapsed-time check,
|
||||
unlike calendar mode's browse_offset (see app/widgets/calendar.py's
|
||||
render()). Accepted anyway so every widget type's render() shares one
|
||||
call signature regardless of which ones actually care."""
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"])
|
||||
|
||||
@@ -22,7 +22,11 @@ from ._shared import placeholder_image
|
||||
ACTION_LABELS = {"check_now": "Check for updates"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused here -- see app/widgets/photos.py's
|
||||
identical note; every widget type's render() shares one call
|
||||
signature regardless of which ones actually care."""
|
||||
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget)
|
||||
if png_bytes is None:
|
||||
return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"])
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""End-to-end HTTP tests for the widget-system cutover in
|
||||
routers/device.py -- /frame/image, /frame/advance, /frame/back, and
|
||||
/frame/share against real widget rows (via the migration-backfilled
|
||||
frame #1, or a purpose-built second frame), a real TestClient, real
|
||||
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
|
||||
at the app.widgets.photos module boundary, same pattern as
|
||||
test_widgets_photos.py) -- everything else in the pipeline is real.
|
||||
|
||||
This is the one place in the suite that actually exercises
|
||||
routers/device.py's dispatch over HTTP; the render-size-invariant tests
|
||||
exercise the renderers directly, and the widget unit tests exercise
|
||||
app/widgets/*.py directly, but neither proves device.py's own wiring
|
||||
(the compositor, the button-action runner, the manage-overlay hookup)
|
||||
is correct -- that's what this file is for."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app import widgets
|
||||
from app.models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
PhotoWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
)
|
||||
|
||||
EXPECTED_BYTES = 800 * 480 // 2
|
||||
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
|
||||
|
||||
|
||||
def _mock_immich(monkeypatch):
|
||||
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||
from PIL import Image
|
||||
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
||||
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
|
||||
|
||||
|
||||
def test_unclaimed_frame_shows_placeholder(client, db_session):
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.owner_user_id = None
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/frame/image")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
|
||||
|
||||
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.get("/frame/image")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert cfg.album_id == "" # never configured -- still rendered fine, as a placeholder region
|
||||
|
||||
|
||||
def test_configured_photo_widget_renders_and_advances_via_button(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
cfg.album_id = "album-1"
|
||||
db_session.commit()
|
||||
|
||||
_mock_immich(monkeypatch)
|
||||
|
||||
resp = client.get("/frame/image")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
|
||||
db_session.refresh(cfg)
|
||||
assert cfg.current_asset_id == "asset-1"
|
||||
|
||||
resp = client.post("/frame/advance")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
db_session.refresh(cfg)
|
||||
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
|
||||
|
||||
resp = client.post("/frame/back")
|
||||
assert resp.status_code == 200
|
||||
db_session.refresh(cfg)
|
||||
assert cfg.current_asset_id == "asset-1" # back undid it
|
||||
|
||||
|
||||
def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
|
||||
db_session.commit()
|
||||
_mock_immich(monkeypatch)
|
||||
|
||||
plain = client.get("/frame/image").content
|
||||
with_manage = client.get("/frame/image?manage=1").content
|
||||
assert len(with_manage) == EXPECTED_BYTES
|
||||
assert with_manage != plain # the manage-QR overlay actually got composited in
|
||||
|
||||
|
||||
def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_session, monkeypatch):
|
||||
"""A second frame (not frame #1) with two independent widgets -- a
|
||||
calendar widget and a photo widget, addressed directly the way the
|
||||
migration's calendar_photo_inlay backfill shapes a frame -- exercised
|
||||
end to end over HTTP, not just via the migration's own unit tests."""
|
||||
_mock_immich(monkeypatch)
|
||||
|
||||
frame = Frame(
|
||||
name="Two Widget Frame", device_id="aabbccddeeff", device_token="devtok-2",
|
||||
manage_token="mtok-2", orientation="landscape", created_at=time.time(),
|
||||
)
|
||||
db_session.add(frame)
|
||||
db_session.flush()
|
||||
|
||||
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=4, h=5,
|
||||
sort_order=0, created_at=time.time())
|
||||
photo_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add_all([cal_widget, photo_widget])
|
||||
db_session.flush()
|
||||
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id, view="agenda", browse_offset=0))
|
||||
db_session.add(PhotoWidgetConfig(widget_id=photo_widget.id, album_id="album-1"))
|
||||
db_session.add_all([
|
||||
FrameButtonAction(frame_id=frame.id, button="next", widget_id=cal_widget.id, action="advance"),
|
||||
FrameButtonAction(frame_id=frame.id, button="back", widget_id=cal_widget.id, action="back"),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
|
||||
# NEXT is bound only to the calendar widget -- pressing it should
|
||||
# move the calendar's browse_offset, not touch the photo widget.
|
||||
resp = client.post(f"/frame/advance?id={frame.device_id}&token={frame.device_token}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
cal_cfg = db_session.get(CalendarWidgetConfig, cal_widget.id)
|
||||
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
||||
assert cal_cfg.browse_offset == 1
|
||||
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
||||
|
||||
|
||||
def test_frame_share_checks_widget_scoped_state(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.owner_user_id = db_session.query(User).filter_by(username="alice").one().id
|
||||
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
cfg.album_id = "album-1"
|
||||
cfg.current_asset_id = "asset-1"
|
||||
cfg.queue = ["asset-2"]
|
||||
frame.immich_url = "http://immich.example.com"
|
||||
frame.immich_api_key = "key"
|
||||
db_session.commit()
|
||||
|
||||
# Not showing/queued -- rejected before ever touching Immich
|
||||
resp = client.get("/frame/share/asset-not-on-this-frame")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Currently showing -- allowed through to the (mocked) Immich call
|
||||
monkeypatch.setattr(
|
||||
"app.routers.device.immich_client_for",
|
||||
lambda frame: type("C", (), {"create_share_link": lambda self, asset_id, expires_in_s: "https://immich.example.com/share/abc"})(),
|
||||
)
|
||||
resp = client.get("/frame/share/asset-1", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
|
||||
# Queued (not current) -- also allowed
|
||||
resp = client.get("/frame/share/asset-2", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""routers/manage.py -- the no-login "scan to manage" surface -- against
|
||||
the widget-scoped photo state it was just repointed at. No prior test
|
||||
coverage existed for this router at all before this file; it's
|
||||
exercised here specifically because of how much its photo-queue
|
||||
endpoints changed in the widget-system cutover (frame.current_asset_id/
|
||||
frame.queue -> the frame's photo widget's own PhotoWidgetConfig)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import Frame, PhotoWidgetConfig
|
||||
|
||||
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
|
||||
|
||||
|
||||
def _mock_immich(monkeypatch):
|
||||
"""manage.py imports immich_client_for/list_assets into its own
|
||||
module namespace (`from .common import ...`) -- patching those
|
||||
names, not app.widgets.photos' separate copies, since manage.py's
|
||||
endpoints call its own bound names directly, never through
|
||||
app/widgets/."""
|
||||
monkeypatch.setattr("app.routers.manage.immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr("app.routers.manage.list_assets", lambda client, album_id: _ASSETS)
|
||||
|
||||
|
||||
def _configure_photo_widget(db_session):
|
||||
from app.models import Widget
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
# photo_widget_config_or_404 (see routers/common.py) checks Immich
|
||||
# creds directly, not through the mocked immich_client_for below.
|
||||
frame.immich_url = "http://immich.example.com"
|
||||
frame.immich_api_key = "key"
|
||||
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
cfg.album_id = "album-1"
|
||||
db_session.commit()
|
||||
return frame, widget
|
||||
|
||||
|
||||
def test_manage_queue_requires_configured_photo_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
resp = client.get(f"/api/m/{frame.manage_token}/queue")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_manage_queue_returns_current_and_upcoming(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, widget = _configure_photo_widget(db_session)
|
||||
_mock_immich(monkeypatch)
|
||||
|
||||
resp = client.get(f"/api/m/{frame.manage_token}/queue")
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["current"]["id"] == "asset-1"
|
||||
assert [u["id"] for u in data["upcoming"]] == ["asset-2", "asset-3"]
|
||||
|
||||
|
||||
def test_manage_advance_and_back(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, widget = _configure_photo_widget(db_session)
|
||||
_mock_immich(monkeypatch)
|
||||
|
||||
# establish a current photo first
|
||||
client.get(f"/api/m/{frame.manage_token}/queue")
|
||||
|
||||
resp = client.post(f"/api/m/{frame.manage_token}/advance")
|
||||
assert resp.status_code == 200
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
first_current = cfg.current_asset_id
|
||||
assert first_current != ""
|
||||
|
||||
resp = client.post(f"/api/m/{frame.manage_token}/advance")
|
||||
assert resp.status_code == 200
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert cfg.current_asset_id != first_current
|
||||
|
||||
resp = client.post(f"/api/m/{frame.manage_token}/back")
|
||||
assert resp.status_code == 200
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert cfg.current_asset_id == first_current
|
||||
|
||||
|
||||
def test_manage_promote(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, widget = _configure_photo_widget(db_session)
|
||||
_mock_immich(monkeypatch)
|
||||
client.get(f"/api/m/{frame.manage_token}/queue") # populate the queue
|
||||
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert "asset-3" in cfg.queue
|
||||
|
||||
resp = client.post(f"/api/m/{frame.manage_token}/promote", json={"asset_id": "asset-3"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert cfg.queue[0] == "asset-3"
|
||||
|
||||
|
||||
def test_manage_thumbnail_scoped_to_showing_or_queued(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame, widget = _configure_photo_widget(db_session)
|
||||
_mock_immich(monkeypatch)
|
||||
client.get(f"/api/m/{frame.manage_token}/queue")
|
||||
|
||||
resp = client.get(f"/api/m/{frame.manage_token}/thumbnail/not-on-this-frame")
|
||||
assert resp.status_code == 404
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.routers.manage.immich_client_for",
|
||||
lambda frame: type("C", (), {
|
||||
"download_asset_thumbnail": lambda self, asset_id: (b"jpegbytes", "image/jpeg"),
|
||||
})(),
|
||||
)
|
||||
resp = client.get(f"/api/m/{frame.manage_token}/thumbnail/asset-1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"jpegbytes"
|
||||
|
||||
|
||||
def test_unknown_manage_token_404s(client, db_session):
|
||||
resp = client.get("/api/m/not-a-real-token/queue")
|
||||
assert resp.status_code == 404
|
||||
@@ -6,7 +6,16 @@ not just a helper function's logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import Frame, FrameCalendar, User
|
||||
import time
|
||||
|
||||
from app.models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
User,
|
||||
Widget,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
@@ -21,26 +30,53 @@ def _setup_two_linked_users(client, db_session) -> Frame:
|
||||
return frame
|
||||
|
||||
|
||||
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
|
||||
"""Frame #1's auto-migrated widget is a photos widget (its mode was
|
||||
"photos" before the widget system existed) -- these tests need a
|
||||
whiteboard widget too, which nothing creates yet until the
|
||||
widget-placement UI (a later phase) ships, so it's added directly
|
||||
here the same way the widget unit tests do."""
|
||||
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _add_calendar_widget(db_session, frame: Frame) -> Widget:
|
||||
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
# --- whiteboard-source ---
|
||||
|
||||
def test_whiteboard_source_owner_can_set_it(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_whiteboard_widget(db_session, frame)
|
||||
# alice is still logged in from /setup
|
||||
resp = client.post("/api/frames/1/whiteboard-source", json={
|
||||
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.whiteboard_user_id is not None
|
||||
assert frame.whiteboard_url == "https://cloud.example.com/dav/files/alice/board.whiteboard"
|
||||
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
|
||||
assert cfg.user_id is not None
|
||||
assert cfg.url == "https://cloud.example.com/dav/files/alice/board.whiteboard"
|
||||
|
||||
|
||||
def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
|
||||
"""bob has no way to point the frame at someone else's account --
|
||||
there's no target-user field in the request at all, so a "set" call
|
||||
from bob always attaches to bob, even if he pastes alice's URL."""
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_whiteboard_widget(db_session, frame)
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
|
||||
@@ -49,13 +85,14 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
bob_row = db_session.query(User).filter_by(username="bob").one()
|
||||
assert frame.whiteboard_user_id == bob_row.id
|
||||
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
|
||||
assert cfg.user_id == bob_row.id
|
||||
|
||||
|
||||
def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_whiteboard_widget(db_session, frame)
|
||||
client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"},
|
||||
headers=csrf_headers(client))
|
||||
|
||||
@@ -64,20 +101,22 @@ def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
|
||||
resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.whiteboard_url == ""
|
||||
assert frame.whiteboard_user_id is None
|
||||
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
|
||||
assert cfg.url == ""
|
||||
assert cfg.user_id is None
|
||||
|
||||
|
||||
def test_whiteboard_source_rejects_non_http_url(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
_add_whiteboard_widget(db_session, frame)
|
||||
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
_add_whiteboard_widget(db_session, frame)
|
||||
make_user(db_session, "mallory") # exists, but never linked to frame 1
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
@@ -87,10 +126,22 @@ def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session):
|
||||
"""Distinct from the unlinked-user 404 above -- this is a linked,
|
||||
fully-permitted owner hitting the endpoint on a frame that simply
|
||||
doesn't have a whiteboard widget yet (frame #1's auto-migrated
|
||||
widget is a photos widget)."""
|
||||
_setup_two_linked_users(client, db_session)
|
||||
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- tasks-source ---
|
||||
|
||||
def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_calendar_widget(db_session, frame)
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
|
||||
@@ -99,13 +150,14 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
bob_row = db_session.query(User).filter_by(username="bob").one()
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.calendar_tasks_user_id == bob_row.id
|
||||
assert frame.calendar_tasks_calendar_key == "caldav:/some/tasks/"
|
||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
||||
assert cfg.tasks_user_id == bob_row.id
|
||||
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
|
||||
|
||||
|
||||
def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
frame = _setup_two_linked_users(client, db_session)
|
||||
widget = _add_calendar_widget(db_session, frame)
|
||||
client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"},
|
||||
headers=csrf_headers(client))
|
||||
|
||||
@@ -114,9 +166,16 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.calendar_tasks_user_id is None
|
||||
assert frame.calendar_tasks_calendar_key is None
|
||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
||||
assert cfg.tasks_user_id is None
|
||||
assert cfg.tasks_calendar_key is None
|
||||
|
||||
|
||||
def test_tasks_source_404s_when_frame_has_no_calendar_widget(client, db_session):
|
||||
_setup_two_linked_users(client, db_session)
|
||||
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- calendar-select ---
|
||||
|
||||
@@ -1,63 +1,72 @@
|
||||
"""get_or_refresh_whiteboard's fetch throttle (and force=True bypassing
|
||||
it) plus the whiteboard-browse HTTP endpoint. whiteboard.fetch_and_render
|
||||
is monkeypatched -- it talks to a real WebDAV server and the Node render
|
||||
sidecar (see render-service/), neither of which this suite needs a real
|
||||
copy of to verify the *throttle*/*permission* logic around it."""
|
||||
"""get_or_refresh_whiteboard_for_widget's fetch throttle (and force=True
|
||||
bypassing it) plus the whiteboard-browse HTTP endpoint.
|
||||
whiteboard.fetch_and_render is monkeypatched -- it talks to a real
|
||||
WebDAV server and the Node render sidecar (see render-service/), neither
|
||||
of which this suite needs a real copy of to verify the *throttle*/
|
||||
*permission* logic around it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app import whiteboard
|
||||
from app.models import Frame, User
|
||||
from app.routers.common import get_or_refresh_whiteboard
|
||||
from app.models import Frame, User, Widget, WhiteboardWidgetConfig
|
||||
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
|
||||
def _configure_whiteboard(db_session, frame: Frame, user: User) -> None:
|
||||
frame.whiteboard_user_id = user.id
|
||||
frame.whiteboard_url = "http://example.invalid/board.whiteboard"
|
||||
frame.whiteboard_cached_image = b"OLD_CACHED_PNG"
|
||||
frame.whiteboard_checked_at = time.time() # just refreshed -- well within the throttle
|
||||
def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget:
|
||||
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(WhiteboardWidgetConfig(
|
||||
widget_id=widget.id, user_id=user.id, url="http://example.invalid/board.whiteboard",
|
||||
cached_image=b"OLD_CACHED_PNG", checked_at=time.time(), # just refreshed -- well within the throttle
|
||||
))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch):
|
||||
from app.routers.common import get_or_refresh_whiteboard_for_widget
|
||||
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
alice = db_session.query(User).filter_by(username="alice").one()
|
||||
alice.webdav_username = "alice"
|
||||
alice.webdav_password = "secret"
|
||||
frame = db_session.get(Frame, 1)
|
||||
_configure_whiteboard(db_session, frame, alice)
|
||||
widget = _configure_whiteboard(db_session, frame, alice)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(whiteboard, "fetch_and_render",
|
||||
lambda url, u, p: calls.append(1) or b"NEW_PNG")
|
||||
|
||||
result = get_or_refresh_whiteboard(db_session, frame)
|
||||
result = get_or_refresh_whiteboard_for_widget(db_session, frame, widget)
|
||||
assert result == b"OLD_CACHED_PNG"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_force_bypasses_throttle_and_persists(client, db_session, monkeypatch):
|
||||
from app.routers.common import get_or_refresh_whiteboard_for_widget
|
||||
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
alice = db_session.query(User).filter_by(username="alice").one()
|
||||
alice.webdav_username = "alice"
|
||||
alice.webdav_password = "secret"
|
||||
frame = db_session.get(Frame, 1)
|
||||
_configure_whiteboard(db_session, frame, alice)
|
||||
widget = _configure_whiteboard(db_session, frame, alice)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(whiteboard, "fetch_and_render",
|
||||
lambda url, u, p: calls.append(1) or b"NEW_PNG")
|
||||
|
||||
result = get_or_refresh_whiteboard(db_session, frame, force=True)
|
||||
result = get_or_refresh_whiteboard_for_widget(db_session, frame, widget, force=True)
|
||||
assert result == b"NEW_PNG"
|
||||
assert len(calls) == 1
|
||||
|
||||
db_session.refresh(frame)
|
||||
assert frame.whiteboard_cached_image == b"NEW_PNG"
|
||||
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
|
||||
assert cfg.cached_image == b"NEW_PNG"
|
||||
|
||||
|
||||
def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session, monkeypatch):
|
||||
@@ -67,10 +76,10 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session
|
||||
alice.webdav_password = "secret"
|
||||
frame = db_session.get(Frame, 1)
|
||||
|
||||
# The preview endpoint runs whatever get_or_refresh_whiteboard returns
|
||||
# through PIL (Image.open) -- unlike the other tests in this file,
|
||||
# the placeholder "cached" bytes need to be real, valid PNG data, not
|
||||
# just an arbitrary marker string.
|
||||
# The preview endpoint runs whatever get_or_refresh_whiteboard_for_widget
|
||||
# returns through PIL (Image.open) -- unlike the other tests in this
|
||||
# file, the placeholder "cached" bytes need to be real, valid PNG
|
||||
# data, not just an arbitrary marker string.
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
@@ -78,10 +87,14 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session
|
||||
Image.new("RGB", (1, 1), (255, 255, 255)).save(buf, format="PNG")
|
||||
tiny_png = buf.getvalue()
|
||||
|
||||
frame.whiteboard_user_id = alice.id
|
||||
frame.whiteboard_url = "http://example.invalid/board.whiteboard"
|
||||
frame.whiteboard_cached_image = tiny_png
|
||||
frame.whiteboard_checked_at = time.time()
|
||||
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(WhiteboardWidgetConfig(
|
||||
widget_id=widget.id, user_id=alice.id, url="http://example.invalid/board.whiteboard",
|
||||
cached_image=tiny_png, checked_at=time.time(),
|
||||
))
|
||||
db_session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
@@ -100,3 +100,28 @@ def test_back_action_decrements_browse_offset(db_session):
|
||||
widgets.calendar.ACTIONS["back"](db_session, frame, widget)
|
||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
||||
assert cfg.browse_offset == 2
|
||||
|
||||
|
||||
def test_normal_wake_resets_browse_offset_to_zero(db_session, monkeypatch):
|
||||
"""A plain /frame/image GET (is_normal_wake=True, the default) should
|
||||
snap browse_offset back to "today" if a previous button press had
|
||||
moved it -- mirrors the old _render_calendar_mode's identical
|
||||
behavior. A button-triggered render (is_normal_wake=False) must NOT
|
||||
do this, or every button press would immediately erase its own
|
||||
effect on the very next render."""
|
||||
frame, widget = _make_widget(db_session, view="week", browse_offset=5)
|
||||
_stub_fetches(monkeypatch)
|
||||
|
||||
widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=True)
|
||||
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 0
|
||||
|
||||
|
||||
def test_button_triggered_render_does_not_reset_browse_offset(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
|
||||
_stub_fetches(monkeypatch)
|
||||
|
||||
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
|
||||
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1
|
||||
|
||||
widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=False)
|
||||
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1
|
||||
|
||||
Reference in New Issue
Block a user