Files
espresso_frame/server/app/routers/frame_pages.py
T
Thomas Faour b5c52004c8
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00:00

230 lines
10 KiB
Python

"""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,
palette_to_hex,
)
from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
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 _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this tasks widget 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 not task_cfg.user_id or not task_cfg.calendar_key:
return None
user = db.get(User, task_cfg.user_id)
if user is None:
return None
label = task_cfg.calendar_key
for c in (user.calendar_caldav_calendars or []):
if f"caldav:{c['href']}" == task_cfg.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,
"calendar_key": task_cfg.calendar_key}
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:
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 <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)
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)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
return templates.TemplateResponse("_widget_dialog_tasks.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"viewer_task_calendars": viewer_task_calendars,
"tasks_source": _tasks_source_info(db, task_cfg),
})
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}")