Files
espresso_frame/server/app/routers/frame_pages.py
T
tfaour a33a3a71e4
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s
Widget system Phase 4b: per-widget gear-icon config dialogs
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

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

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

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

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
2026-07-24 14:31:24 -04:00

223 lines
9.9 KiB
Python

"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
canvas), Configuration, and Stats, all inside the sidebar app shell.
Each widget's own settings (album, calendar view/inclusion, whiteboard
source, etc.) no longer have their own tab/page -- they're a dialog
opened from a gear icon on the widget's box in the Layout canvas (see
static/frame_layout.js), whose content this module also serves (the
/widgets/{widget_id}/dialog route) as a small HTML fragment, not a full
page. Data loading otherwise happens client-side against
/api/frames/{id}/... (routers/api_frames.py, routers/api_widgets.py);
these routes just authorize and render the scaffold."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..image_pipeline import (
DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS,
PALETTE_LABELS,
palette_to_hex,
)
from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
User,
UserFrame,
WhiteboardWidgetConfig,
Widget,
)
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context, widget_of_type
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra):
user = current_user(request, db)
if user is None:
return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303)
frame = db.get(Frame, frame_id)
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})
return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
frame = db.get(Frame, frame_id)
photo_widget_id = None
if frame is not None:
photo_widget = widget_of_type(db, frame, "photos")
if photo_widget is not None:
photo_widget_id = photo_widget.id
return _frame_page(
request, db, frame_id, "frame_config.html", "config",
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id,
)
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
# --- Per-widget config dialog content -------------------------------------
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any widget:
the single ICS subscription (if set) plus every CalDAV calendar last
discovered from Settings' "Discover calendars" button. Doesn't hit
the network -- reads the cached list a user refreshes themselves."""
calendars = []
if user.calendar_ics_url:
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
for c in (user.calendar_caldav_calendars or []):
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
return calendars
def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user calendar list for the calendar dialog's "Included
calendars" section. The viewer's own row lists EVERY calendar they
have available, each with a full add/remove toggle; every other
linked user's row lists ONLY the calendars they've already included
(mute-only for the viewer -- see api_widgets.py's
api_widget_calendar_select: only a calendar's owner may turn it on,
but anyone linked to the frame may turn one off)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameCalendar]] = {}
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(fc.user_id, []).append(fc)
result = []
for u in users:
is_self = u.id == viewer_id
if is_self:
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
calendars = [
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in _user_available_calendars(u)
]
else:
calendars = [
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
for fc in included_by_user.get(u.id, []) if fc.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "calendars": calendars,
})
return result
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this widget'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 calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
return None
user = db.get(User, calendar_cfg.tasks_user_id)
if user is None:
return None
label = calendar_cfg.tasks_calendar_key
for c in (user.calendar_caldav_calendars or []):
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}
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
"""Whose account this widget 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 whiteboard_cfg.user_id or not whiteboard_cfg.url:
return None
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": whiteboard_cfg.url}
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
4: "Friday", 5: "Saturday", 6: "Sunday"}
@router.get("/frames/{frame_id}/widgets/{widget_id}/dialog", response_class=HTMLResponse)
def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = Depends(get_db)):
"""The gear-icon dialog's content, dispatched by widget_type -- a
small HTML fragment (no app_base shell/tabs), fetched and injected
into a <dialog> by static/frame_layout.js. Not itself a page a user
would navigate to directly."""
user = current_user(request, db)
if user is None:
raise HTTPException(401, "Not logged in")
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
"display_mode_labels": DISPLAY_MODE_LABELS,
})
if widget.widget_type == "calendar":
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
return templates.TemplateResponse("_widget_dialog_calendar.html", {
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
"calendar_views": CALENDAR_VIEW_LABELS,
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
"viewer_task_calendars": viewer_task_calendars,
"tasks_source": _tasks_source_info(db, calendar_cfg),
})
if widget.widget_type == "whiteboard":
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
viewer_has_webdav_creds = bool(
user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username)
)
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")