"""CRUD + grid placement for a frame's widgets (see models.Widget) -- backs the Layout tab's placement canvas (static/frame_widget_canvas.js). Every mutation re-validates bounds/minimum footprint/no-overlap server-side regardless of what the client already checked -- the client's own checks are UX, not the source of truth (this project's usual posture, e.g. api_frames.py's own field clamps).""" from __future__ import annotations import time from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy import 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, WIDGET_CONFIG_MODELS, Widget from ..widgets import WIDGET_TYPES router = APIRouter() def _widget_dict(w: Widget) -> dict: return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, "sort_order": w.sort_order} @router.get("/api/frames/{frame_id}/widgets") def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): user = require_user_api(request, db) cols, rows = grid.grid_dims(frame.orientation) widgets = db.scalars( select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) ).all() return { "orientation": frame.orientation, "grid": {"cols": cols, "rows": rows}, "widget_types": list(WIDGET_TYPES.keys()), "min_footprint": grid.MIN_FOOTPRINT, "widgets": [_widget_dict(w) for w in widgets], "control": { "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None, "you": frame.controlled_by_user_id == user.id, }, } def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]: widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all() return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id] def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect, exclude_widget_id: int | None = None) -> None: if not grid.in_bounds(frame.orientation, rect): raise HTTPException(400, "Placement is out of bounds for this frame's grid") if not grid.meets_minimum(widget_type, rect): min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1)) raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells") for other_rect in _other_rects(db, frame.id, exclude_widget_id): if grid.overlaps(rect, other_rect): raise HTTPException(400, "Overlaps another widget") class WidgetCreateRequest(BaseModel): widget_type: str x: int | None = None y: int | None = None w: int | None = None h: int | None = None @router.post("/api/frames/{frame_id}/widgets") def api_widget_create( body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) ): if body.widget_type not in WIDGET_TYPES: raise HTTPException(400, f"Unknown widget type: {body.widget_type}") min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1)) w, h = body.w or min_w, body.h or min_h if body.x is None or body.y is None: rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h) if rect is None: raise HTTPException(400, "No open space left for a widget this size") else: rect = (body.x, body.y, w, h) _validate_placement(db, frame, body.widget_type, rect) with frame_locked(db, frame.id): max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id)) x, y, w, h = rect widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h, sort_order=(max_sort or 0) + 1, created_at=time.time()) db.add(widget) db.flush() db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id)) db.commit() return _widget_dict(widget) class WidgetPlacementRequest(BaseModel): x: int y: int w: int h: int @router.patch("/api/frames/{frame_id}/widgets/{widget_id}") def api_widget_move( widget_id: int, body: WidgetPlacementRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): widget = db.get(Widget, widget_id) if widget is None or widget.frame_id != frame.id: raise HTTPException(404, "No such widget") rect = (body.x, body.y, body.w, body.h) _validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id) with frame_locked(db, frame.id): widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h db.commit() return _widget_dict(widget) @router.delete("/api/frames/{frame_id}/widgets/{widget_id}") def api_widget_delete( widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) ): """Cascades to the widget's own config row and any FrameButtonAction bindings that pointed at it (both ondelete="CASCADE" FKs, see models.py) -- nothing left pointing at a widget id that no longer exists.""" widget = db.get(Widget, widget_id) if widget is None or widget.frame_id != frame.id: raise HTTPException(404, "No such widget") with frame_locked(db, frame.id): db.delete(widget) db.commit() return {"status": "deleted"}