Files
espresso_frame/server/app/routers/frame_pages.py
T
tfaour acdb929a99
Build and push server image / build-and-push (push) Successful in 50s
Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Calendar rule/grid lines were light gray, which dithers away to
near-invisible on the 6-color e-ink palette -- now black.

CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked
alongside the existing single ICS subscription, since one account can
expose several calendars. A frame's Calendar tab now lists calendars
per person rather than one opt-in per person: your own row shows every
calendar you have available with a full add/remove toggle, while other
linked users' rows show only calendars they've included, toggleable
off (mute) but not on -- only a calendar's owner can add it to a
shared frame. FrameCalendar replaces the old single-boolean
UserFrame.calendar_included; existing opt-ins are migrated forward.
2026-07-22 22:01:05 -04:00

127 lines
5.3 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 Frame, FrameCalendar, User, UserFrame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
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_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
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:
included = {fc.calendar_key: fc.included for fc in included_by_user.get(u.id, [])}
calendars = [
{**c, "included": included.get(c["key"], False)}
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
@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,
display_mode_labels=DISPLAY_MODE_LABELS,
)
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)
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,
)
@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")