New widget type with four display modes -- current conditions, an hourly forecast strip, a multi-day forecast, and several cities' current day side by side -- backed by a pluggable provider registry (app/weather/, mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and NWS (US-only) both wired up now, Environment Canada documented as the next one to add given its more involved station/grid-lookup API. The calendar widget's existing embedded weather strip is untouched and still Open-Meteo-only; this lifts the same underlying icon-drawing primitives (now shared via app/weather_render.py, calendar_render.py still imports draw_weather_row unchanged) into a widget that can be placed and sized on its own. Icons are redrawn in the panel's actual ink colors (yellow sun/bolt, blue rain/snow) instead of flat black, and build_multi_city's icon/font sizing now scales with how many cities need to fit rather than the box's height alone -- both fixed after catching them via live browser verification, along with a mode-switch cache-shape crash and a mobile-width dialog overflow. New WeatherWidgetConfig table (migration 24), grid footprint, widget module, common.py fetch/cache helper, router endpoints (location set/ clear, city add/remove, preview), dialog template + JS, and full test coverage (providers, widget render, HTTP endpoints, migration replay). docs/widgets.md and CLAUDE.md's TODO updated accordingly.
278 lines
12 KiB
Python
278 lines
12 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 .. import weather
|
|
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,
|
|
TextWidgetConfig,
|
|
User,
|
|
UserFrame,
|
|
WeatherWidgetConfig,
|
|
WhiteboardWidgetConfig,
|
|
Widget,
|
|
)
|
|
from ..quiet_hours import ALL_TIMEZONES
|
|
from ..widgets import text as text_widget
|
|
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 <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)
|
|
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 == "text":
|
|
text_cfg = db.get(TextWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_text.html", {
|
|
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
|
"text_font_families": text_widget.FONT_FAMILIES,
|
|
})
|
|
|
|
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,
|
|
})
|
|
|
|
if widget.widget_type == "weather":
|
|
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
|
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
|
|
"weather_provider_labels": weather.PROVIDER_LABELS,
|
|
})
|
|
|
|
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|