"""Saved layouts: a named snapshot of one frame's widget arrangement (types, placement, per-widget settings, button assignments) that a user can capture and later apply to any frame they control whose grid matches -- see models.SavedLayout and docs/widgets.md's "Saved layouts" section. Layouts are owned by a *user*, not a frame (SavedLayout.user_id), so every endpoint here except save/apply/list (which need a frame_id to act on) is frame-agnostic -- /api/layouts/{id} rather than /api/frames/{fid}/ layouts/{id}, gated purely on "is this your own saved layout" rather than frame view/control. Save/apply DO live under /api/frames/{frame_id}/... (require_frame_ control) since they read/replace one specific frame's actual widgets. """ from __future__ import annotations import time from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from .. import grid from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db from ..models import ( Frame, FrameButtonAction, FrameCalendar, FrameTaskList, SavedLayout, SavedLayoutButtonAction, SavedLayoutSource, SavedLayoutWidget, User, WIDGET_CONFIG_MODELS, Widget, ) router = APIRouter() MAX_LAYOUT_NAME_LEN = 60 # Per-widget-type allowlist of config columns that are an actual authored # *setting* (captured/restored by a saved layout) as opposed to runtime/ # cache state (a photo widget's current queue position, a calendar's # fetch cache, a whiteboard's rendered-image cache, ...) which a layout # deliberately leaves out -- applying a layout should feel like a fresh # widget of that type with these settings pre-filled, not a resurrection # of stale queue/cache state from whenever it was saved. "static" # excludes `image` on purpose -- that BLOB lives on SavedLayoutWidget.image # instead (see its own model docstring). LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = { "photos": ("album_id", "order", "display_mode", "queue_target_len"), "calendar": ( "view", "week_start", "weather_enabled", "weather_units", "weather_cities", "week_days", "week_layout", "week_start_offset", ), "tasks": ("name", "show_completed"), "static": ("display_mode", "original_filename"), "text": ("content", "font_size", "font_family", "align", "background_color"), "whiteboard": ("user_id", "url"), } # widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind) SOURCE_MODELS: dict[str, tuple[type, str]] = { "calendar": (FrameCalendar, "calendar"), "tasks": (FrameTaskList, "task"), } def _layout_summary(db: Session, layout: SavedLayout, frame: Frame | None = None) -> dict: widget_count = db.scalar( select(func.count()).select_from(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id) ) result = { "id": layout.id, "name": layout.name, "cols": layout.cols, "rows": layout.rows, "widget_count": widget_count or 0, "created_at": layout.created_at, "updated_at": layout.updated_at, } if frame is not None: cols, rows = grid.grid_dims(frame.orientation) result["compatible"] = (layout.cols, layout.rows) == (cols, rows) return result def _user_owned_layout(db: Session, layout_id: int, user: User) -> SavedLayout: layout = db.get(SavedLayout, layout_id) if layout is None or layout.user_id != user.id: raise HTTPException(404, "No such saved layout") return layout @router.get("/api/frames/{frame_id}/layouts") def api_layouts_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): """Every saved layout owned by the calling user (saved layouts are global to a user, not scoped to this or any other frame) -- includes a `compatible` flag per layout for whether it can actually be applied to *this* frame's current grid, so the UI can offer incompatible ones greyed-out with a reason rather than hiding them outright.""" user = require_user_api(request, db) layouts = db.scalars( select(SavedLayout).where(SavedLayout.user_id == user.id).order_by(SavedLayout.name) ).all() return {"layouts": [_layout_summary(db, layout, frame) for layout in layouts]} class LayoutSaveRequest(BaseModel): name: str def _snapshot_widget_config(widget: Widget, config) -> dict: fields = LAYOUT_CONFIG_FIELDS.get(widget.widget_type, ()) return {field: getattr(config, field) for field in fields} def _snapshot_sources(db: Session, widget_id: int, model: type) -> list[dict]: rows = db.scalars(select(model).where(model.widget_id == widget_id)).all() return [ { "user_id": row.user_id, "calendar_key": row.calendar_key, "calendar_label": row.calendar_label, "included": row.included, "color_index": row.color_index, } for row in rows ] @router.post("/api/frames/{frame_id}/layouts") def api_layout_save( body: LayoutSaveRequest, request: Request, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Snapshots this frame's current widgets (placement + settings, see LAYOUT_CONFIG_FIELDS), their calendar/task sources, and their button-action bindings into a named layout owned by the calling user. Saving again with a name this user already has overwrites that layout's snapshot in place (see models.SavedLayout's docstring) rather than erroring or creating a duplicate.""" user = require_user_api(request, db) name = body.name.strip() if not name: raise HTTPException(400, "Name is required") if len(name) > MAX_LAYOUT_NAME_LEN: raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer") cols, rows = grid.grid_dims(frame.orientation) widgets = db.scalars( select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) ).all() now = time.time() existing = db.execute( select(SavedLayout).where(SavedLayout.user_id == user.id, SavedLayout.name == name) ).scalar_one_or_none() if existing is not None: layout = existing layout.cols, layout.rows, layout.updated_at = cols, rows, now db.execute(delete(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id)) db.flush() else: layout = SavedLayout(user_id=user.id, name=name, cols=cols, rows=rows, created_at=now, updated_at=now) db.add(layout) db.flush() snapshot_id_by_widget_id: dict[int, int] = {} for widget in widgets: config = db.get(WIDGET_CONFIG_MODELS[widget.widget_type], widget.id) snapshot = SavedLayoutWidget( saved_layout_id=layout.id, widget_type=widget.widget_type, x=widget.x, y=widget.y, w=widget.w, h=widget.h, sort_order=widget.sort_order, config=_snapshot_widget_config(widget, config), image=config.image if widget.widget_type == "static" else None, ) db.add(snapshot) db.flush() snapshot_id_by_widget_id[widget.id] = snapshot.id source_model = SOURCE_MODELS.get(widget.widget_type) if source_model is not None: model, kind = source_model for source in _snapshot_sources(db, widget.id, model): db.add(SavedLayoutSource(saved_layout_widget_id=snapshot.id, kind=kind, **source)) actions = db.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() for action in actions: snapshot_id = snapshot_id_by_widget_id.get(action.widget_id) if snapshot_id is None: continue db.add(SavedLayoutButtonAction( saved_layout_widget_id=snapshot_id, button=action.button, action=action.action, sort_order=action.sort_order, )) db.commit() return _layout_summary(db, layout, frame) class LayoutRenameRequest(BaseModel): name: str @router.patch("/api/layouts/{layout_id}") def api_layout_rename(layout_id: int, body: LayoutRenameRequest, request: Request, db: Session = Depends(get_db)): user = require_user_api(request, db) layout = _user_owned_layout(db, layout_id, user) name = body.name.strip() if not name: raise HTTPException(400, "Name is required") if len(name) > MAX_LAYOUT_NAME_LEN: raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer") conflict = db.execute( select(SavedLayout).where( SavedLayout.user_id == user.id, SavedLayout.name == name, SavedLayout.id != layout.id ) ).scalar_one_or_none() if conflict is not None: raise HTTPException(400, "You already have a saved layout with that name") layout.name = name layout.updated_at = time.time() db.commit() return _layout_summary(db, layout) @router.delete("/api/layouts/{layout_id}") def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(get_db)): user = require_user_api(request, db) layout = _user_owned_layout(db, layout_id, user) db.delete(layout) db.commit() return {"status": "deleted"} @router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply") def api_layout_apply( layout_id: int, request: Request, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Replaces this frame's entire widget arrangement with a saved layout's -- every current widget (and its own config/sources/button actions, all ondelete="CASCADE") is deleted first, same "act unconditionally on the server, confirm on the client" posture as api_widgets.api_widgets_clear. A source whose owning user account (or a whiteboard's user_id) no longer exists is silently dropped rather than left dangling -- config is JSON, not FK-checked, so nothing enforces that at the storage layer.""" user = require_user_api(request, db) layout = _user_owned_layout(db, layout_id, user) cols, rows = grid.grid_dims(frame.orientation) if (layout.cols, layout.rows) != (cols, rows): raise HTTPException(400, "This layout was saved for a different frame size/orientation") snapshots = db.scalars( select(SavedLayoutWidget) .where(SavedLayoutWidget.saved_layout_id == layout.id) .order_by(SavedLayoutWidget.sort_order) ).all() with frame_locked(db, frame.id): for widget in db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all(): db.delete(widget) db.flush() new_widget_id_by_snapshot_id: dict[int, int] = {} for snapshot in snapshots: widget = Widget( frame_id=frame.id, widget_type=snapshot.widget_type, x=snapshot.x, y=snapshot.y, w=snapshot.w, h=snapshot.h, sort_order=snapshot.sort_order, created_at=time.time(), ) db.add(widget) db.flush() new_widget_id_by_snapshot_id[snapshot.id] = widget.id config = WIDGET_CONFIG_MODELS[snapshot.widget_type](widget_id=widget.id) for field, value in snapshot.config.items(): if field == "user_id" and value is not None and db.get(User, value) is None: value = None # that WebDAV account no longer exists setattr(config, field, value) if snapshot.widget_type == "static" and snapshot.image is not None: config.image = snapshot.image config.uploaded_at = time.time() db.add(config) source_model = SOURCE_MODELS.get(snapshot.widget_type) if source_model is not None: model, kind = source_model sources = db.scalars( select(SavedLayoutSource).where( SavedLayoutSource.saved_layout_widget_id == snapshot.id, SavedLayoutSource.kind == kind ) ).all() for source in sources: if db.get(User, source.user_id) is None: continue # that account no longer exists db.add(model( widget_id=widget.id, user_id=source.user_id, calendar_key=source.calendar_key, calendar_label=source.calendar_label, included=source.included, color_index=source.color_index, )) for snapshot in snapshots: actions = db.scalars( select(SavedLayoutButtonAction) .where(SavedLayoutButtonAction.saved_layout_widget_id == snapshot.id) .order_by(SavedLayoutButtonAction.sort_order) ).all() for action in actions: db.add(FrameButtonAction( frame_id=frame.id, button=action.button, widget_id=new_widget_id_by_snapshot_id[snapshot.id], action=action.action, sort_order=action.sort_order, created_at=time.time(), )) db.commit() return {"status": "applied", "widget_count": len(snapshots)}