from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from .. import models from ..database import get_db router = APIRouter(tags=["formboard"]) # ── Helpers ─────────────────────────────────────────────────────────────────── def _get_or_create(diagram_id: int, db: Session) -> models.FormboardLayout: layout = db.query(models.FormboardLayout).filter_by(diagram_id=diagram_id).first() if not layout: layout = models.FormboardLayout(diagram_id=diagram_id) db.add(layout) db.commit() db.refresh(layout) return layout def _nd(n: models.FormboardNode) -> dict: return { "id": n.id, "layout_id": n.layout_id, "node_type": n.node_type, "device_id": n.device_id, "x": n.x, "y": n.y, "label": n.label, "notes": n.notes, } def _sd(s: models.FormboardSegment) -> dict: return { "id": s.id, "layout_id": s.layout_id, "from_node_id": s.from_node_id, "to_node_id": s.to_node_id, "waypoints": s.waypoints or [], "wire_ids": s.wire_ids or [], "fitting_type": s.fitting_type, "fitting_color": s.fitting_color, "label": s.label, "length_mm": s.length_mm, } # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/{diagram_id}") def get_layout(diagram_id: int, db: Session = Depends(get_db)): layout = _get_or_create(diagram_id, db) return { "id": layout.id, "diagram_id": layout.diagram_id, "nodes": [_nd(n) for n in layout.nodes], "segments": [_sd(s) for s in layout.segments], } @router.post("/{diagram_id}/sync") def sync_from_diagram(diagram_id: int, db: Session = Depends(get_db)): """Sync devices as connector nodes and build segments from wire connections. Layout strategy: - Connectors alternate above/below a central trunk line (y=100 / y=300) - Segments are auto-created for each unique device-pair connected by wires, with wire_ids populated so thickness reflects the bundle size. Existing nodes are label-synced but not moved; existing segments are not duplicated. """ diagram = db.query(models.Diagram).filter_by(id=diagram_id).first() if not diagram: raise HTTPException(404, "Diagram not found") layout = _get_or_create(diagram_id, db) existing_dev_ids = {n.device_id for n in layout.nodes if n.device_id is not None} dev_to_node: dict = {n.device_id: n for n in layout.nodes if n.device_id is not None} added = 0 new_x = 80 + len([n for n in layout.nodes if n.device_id is not None]) * 200 for i, dev in enumerate(diagram.devices): label = dev.reference or dev.label or f"Dev {dev.id}" if dev.id in existing_dev_ids: node = dev_to_node[dev.id] if not node.label or node.label == f"Dev {dev.id}": node.label = label else: # Alternate connectors above (y=80) and below (y=320) the trunk row_y = 80 if added % 2 == 0 else 320 node = models.FormboardNode( layout_id=layout.id, node_type="connector", device_id=dev.id, x=new_x, y=row_y, label=label, ) db.add(node) db.flush() # get node.id dev_to_node[dev.id] = node new_x += 200 added += 1 # Build segments from wire connections # Group wires by (min_dev_id, max_dev_id) pair to avoid direction duplicates from collections import defaultdict pair_wires: dict = defaultdict(list) for wire in diagram.wires: a, b = wire.from_device_id, wire.to_device_id if a and b and a != b: pair_wires[(min(a, b), max(a, b))].append(wire.id) # Find existing segments by node-pair to avoid duplicates existing_seg_pairs = set() for seg in layout.segments: existing_seg_pairs.add((min(seg.from_node_id, seg.to_node_id), max(seg.from_node_id, seg.to_node_id))) segs_added = 0 for (dev_a, dev_b), wire_ids in pair_wires.items(): node_a = dev_to_node.get(dev_a) node_b = dev_to_node.get(dev_b) if not node_a or not node_b: continue pair = (min(node_a.id, node_b.id), max(node_a.id, node_b.id)) if pair in existing_seg_pairs: continue seg = models.FormboardSegment( layout_id=layout.id, from_node_id=node_a.id, to_node_id=node_b.id, wire_ids=wire_ids, fitting_type="open", ) db.add(seg) existing_seg_pairs.add(pair) segs_added += 1 db.commit() db.refresh(layout) return { "added": added, "segs_added": segs_added, "nodes": [_nd(n) for n in layout.nodes], "segments": [_sd(s) for s in layout.segments], } # ── Nodes ───────────────────────────────────────────────────────────────────── class NodeCreate(BaseModel): node_type: str = "branch" x: float = 100 y: float = 100 label: str = "" notes: str = "" device_id: Optional[int] = None class NodeUpdate(BaseModel): x: Optional[float] = None y: Optional[float] = None label: Optional[str] = None notes: Optional[str] = None @router.post("/{diagram_id}/nodes", status_code=201) def create_node(diagram_id: int, body: NodeCreate, db: Session = Depends(get_db)): layout = _get_or_create(diagram_id, db) node = models.FormboardNode( layout_id=layout.id, node_type=body.node_type, device_id=body.device_id, x=body.x, y=body.y, label=body.label, notes=body.notes, ) db.add(node) db.commit() db.refresh(node) return _nd(node) @router.patch("/nodes/{node_id}") def update_node(node_id: int, body: NodeUpdate, db: Session = Depends(get_db)): node = db.query(models.FormboardNode).filter_by(id=node_id).first() if not node: raise HTTPException(404) if body.x is not None: node.x = body.x if body.y is not None: node.y = body.y if body.label is not None: node.label = body.label if body.notes is not None: node.notes = body.notes db.commit() db.refresh(node) return _nd(node) @router.delete("/nodes/{node_id}", status_code=204) def delete_node(node_id: int, db: Session = Depends(get_db)): node = db.query(models.FormboardNode).filter_by(id=node_id).first() if not node: raise HTTPException(404) # Cascade-delete connected segments db.query(models.FormboardSegment).filter( (models.FormboardSegment.from_node_id == node_id) | (models.FormboardSegment.to_node_id == node_id) ).delete(synchronize_session=False) db.delete(node) db.commit() # ── Segments ────────────────────────────────────────────────────────────────── class SegCreate(BaseModel): from_node_id: int to_node_id: int wire_ids: List[int] = [] fitting_type: str = "open" fitting_color: str = "#888888" label: str = "" length_mm: Optional[float] = None class SegUpdate(BaseModel): waypoints: Optional[List] = None wire_ids: Optional[List[int]] = None fitting_type: Optional[str] = None fitting_color: Optional[str] = None label: Optional[str] = None length_mm: Optional[float] = None @router.post("/{diagram_id}/segments", status_code=201) def create_segment(diagram_id: int, body: SegCreate, db: Session = Depends(get_db)): layout = _get_or_create(diagram_id, db) seg = models.FormboardSegment( layout_id=layout.id, from_node_id=body.from_node_id, to_node_id=body.to_node_id, wire_ids=body.wire_ids, fitting_type=body.fitting_type, fitting_color=body.fitting_color, label=body.label, length_mm=body.length_mm, ) db.add(seg) db.commit() db.refresh(seg) return _sd(seg) @router.patch("/segments/{seg_id}") def update_segment(seg_id: int, body: SegUpdate, db: Session = Depends(get_db)): seg = db.query(models.FormboardSegment).filter_by(id=seg_id).first() if not seg: raise HTTPException(404) if body.waypoints is not None: seg.waypoints = body.waypoints if body.wire_ids is not None: seg.wire_ids = body.wire_ids if body.fitting_type is not None: seg.fitting_type = body.fitting_type if body.fitting_color is not None: seg.fitting_color = body.fitting_color if body.label is not None: seg.label = body.label if body.length_mm is not None: seg.length_mm = body.length_mm db.commit() db.refresh(seg) return _sd(seg) @router.delete("/segments/{seg_id}", status_code=204) def delete_segment(seg_id: int, db: Session = Depends(get_db)): seg = db.query(models.FormboardSegment).filter_by(id=seg_id).first() if not seg: raise HTTPException(404) db.delete(seg) db.commit()