f3564cbbb0
Backend formboard router and frontend (canvas, formboard, connector library, app/api, index/css) updates. Ignore *.log.
262 lines
9.4 KiB
Python
262 lines
9.4 KiB
Python
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()
|