"""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, STATIC_DISPLAY_MODES, palette_to_hex, ) from ..models import ( CalendarWidgetConfig, Frame, FrameCalendar, FrameTaskList, PhotoWidgetConfig, StaticWidgetConfig, TaskWidgetConfig, 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 _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]: """Per-linked-user task-list list for the tasks dialog's "Included task lists" section -- same shape as _calendar_users_for_widget, restricted to CalDAV calendars only (no "ics" option: a plain ICS subscription has no VTODO collection to speak of, see caldav_client.fetch_tasks).""" 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[FrameTaskList]] = {} for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all(): included_by_user.setdefault(ftl.user_id, []).append(ftl) result = [] for u in users: is_self = u.id == viewer_id available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")] if is_self: own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])} task_lists = [ {**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 available ] else: task_lists = [ {"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True} for ftl in included_by_user.get(u.id, []) if ftl.included ] result.append({ "user_id": u.id, "display_name": u.display_name or u.username, "is_self": is_self, "task_lists": task_lists, }) return result def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None: """Whose account this widget currently fetches with, for showing "using '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 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) 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, }) if widget.widget_type == "tasks": task_cfg = db.get(TaskWidgetConfig, widget.id) return templates.TemplateResponse("_widget_dialog_tasks.html", { "request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user, "task_users": _task_users_for_widget(db, frame.id, widget.id, user.id), "task_color_labels": PALETTE_LABELS, "default_palette_rgb": DEFAULT_PALETTE_RGB, "palette_to_hex": palette_to_hex, }) if widget.widget_type == "static": static_cfg = db.get(StaticWidgetConfig, widget.id) return templates.TemplateResponse("_widget_dialog_static.html", { "request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg, "display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES}, }) 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}")