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.
208 lines
9.2 KiB
Python
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") |