Widget system Phase 4b: per-widget gear-icon config dialogs
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.
Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.
API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.
UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.
Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
This commit is contained in:
+110
-101
@@ -1,6 +1,12 @@
|
||||
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration,
|
||||
Calendar, and Stats tabs, all inside the sidebar app shell. Data loading
|
||||
happens client-side against /api/frames/{id}/... (routers/api_frames.py);
|
||||
"""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
|
||||
@@ -20,7 +26,16 @@ from ..image_pipeline import (
|
||||
PALETTE_LABELS,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig
|
||||
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
|
||||
|
||||
@@ -36,37 +51,42 @@ 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,
|
||||
"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,
|
||||
})
|
||||
ctx.update({"frame": frame, "active_tab": tab, **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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/layout", 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 frame:
|
||||
"""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."""
|
||||
@@ -78,20 +98,20 @@ def _user_available_calendars(user: User) -> list[dict]:
|
||||
return calendars
|
||||
|
||||
|
||||
def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
|
||||
"""Per-linked-user calendar list for the Calendar tab's "Included
|
||||
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_frames.py's api_calendar_select:
|
||||
only a calendar's owner may turn it on, but anyone linked to the
|
||||
frame may turn one off)."""
|
||||
(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.frame_id == frame_id)).scalars().all():
|
||||
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 = []
|
||||
@@ -116,12 +136,12 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
|
||||
return result
|
||||
|
||||
|
||||
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None:
|
||||
"""Whose CalDAV calendar this frame's week-view task list currently
|
||||
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 calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
|
||||
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:
|
||||
@@ -134,52 +154,11 @@ def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
|
||||
4: "Friday", 5: "Saturday", 6: "Sunday"}
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
|
||||
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
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,
|
||||
calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
|
||||
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,
|
||||
calendar_cfg=calendar_cfg,
|
||||
tasks_source=_tasks_source_info(db, calendar_cfg),
|
||||
)
|
||||
|
||||
|
||||
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 whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url:
|
||||
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:
|
||||
@@ -187,27 +166,57 @@ def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig
|
||||
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)
|
||||
def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
viewer = current_user(request, db)
|
||||
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)
|
||||
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):
|
||||
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(
|
||||
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
|
||||
user.webdav_username or (user.webdav_reuse_caldav_creds and user.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, whiteboard_cfg),
|
||||
viewer_has_webdav_creds=viewer_has_webdav_creds,
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@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")
|
||||
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||
|
||||
Reference in New Issue
Block a user