Files
espresso_frame/server/app/routers/frame_pages.py
T
tfaour 37bd657299
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s
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.
2026-07-24 09:26:28 -04:00

208 lines
9.2 KiB
Python

"""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);
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
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,
"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,
)
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any frame:
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_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user calendar list for the Calendar tab'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)."""
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():
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 | 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 calendar_cfg is None or 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}
@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:
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}
@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)
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, 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")