Initial commit — wiring diagram maker with full feature set

- FastAPI + SQLite backend with routers for diagrams, devices, wires,
  bundles, connectors, export, and Octopart/Nexar search
- Konva.js canvas: ortho/direct/curved routing, wire crossings, harness
  mode, snap-to-grid, multi-select, drag, resize, undo
- Wire features: color/stripe, twisted pair helix, shielded glow,
  gauge size label, multi-core bundle grouping, length/unit tracking
- Device library: connector, terminal block, cable, splice, label, group,
  relay, fuse, switch, component with custom connector support
- Exports: BOM CSV, assembly TXT, JSON, formboard layout, PNG image
- Auto-migration for schema changes via ALTER TABLE at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:51:21 -04:00
commit f32203b630
26 changed files with 5667 additions and 0 deletions
View File
+45
View File
@@ -0,0 +1,45 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["bundles"])
@router.get("/diagram/{diagram_id}", response_model=List[schemas.WireBundleOut])
def list_bundles(diagram_id: int, db: Session = Depends(get_db)):
return db.query(models.WireBundle).filter(models.WireBundle.diagram_id == diagram_id).all()
@router.post("/", response_model=schemas.WireBundleOut, status_code=201)
def create_bundle(payload: schemas.WireBundleCreate, db: Session = Depends(get_db)):
bundle = models.WireBundle(**payload.model_dump())
db.add(bundle)
db.commit()
db.refresh(bundle)
return bundle
@router.patch("/{bundle_id}", response_model=schemas.WireBundleOut)
def update_bundle(bundle_id: int, payload: schemas.WireBundleUpdate, db: Session = Depends(get_db)):
bundle = db.get(models.WireBundle, bundle_id)
if not bundle:
raise HTTPException(404, "Bundle not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(bundle, k, v)
db.commit()
db.refresh(bundle)
return bundle
@router.delete("/{bundle_id}", status_code=204)
def delete_bundle(bundle_id: int, db: Session = Depends(get_db)):
bundle = db.get(models.WireBundle, bundle_id)
if not bundle:
raise HTTPException(404, "Bundle not found")
# Detach wires before deleting
for wire in bundle.wires:
wire.bundle_id = None
db.delete(bundle)
db.commit()
+47
View File
@@ -0,0 +1,47 @@
import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["connectors"])
@router.get("/", response_model=List[schemas.CustomConnector])
def list_connectors(db: Session = Depends(get_db)):
return db.query(models.CustomConnector).order_by(models.CustomConnector.category, models.CustomConnector.name).all()
@router.post("/", response_model=schemas.CustomConnector)
def create_connector(connector: schemas.CustomConnectorCreate, db: Session = Depends(get_db)):
db_conn = models.CustomConnector(**connector.model_dump())
db.add(db_conn)
db.commit()
db.refresh(db_conn)
return db_conn
@router.patch("/{connector_id}", response_model=schemas.CustomConnector)
def update_connector(connector_id: int, update: schemas.CustomConnectorUpdate, db: Session = Depends(get_db)):
conn = db.query(models.CustomConnector).filter(models.CustomConnector.id == connector_id).first()
if not conn:
raise HTTPException(status_code=404, detail="Connector not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(conn, key, value)
conn.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(conn)
return conn
@router.delete("/{connector_id}")
def delete_connector(connector_id: int, db: Session = Depends(get_db)):
conn = db.query(models.CustomConnector).filter(models.CustomConnector.id == connector_id).first()
if not conn:
raise HTTPException(status_code=404, detail="Connector not found")
db.delete(conn)
db.commit()
return {"ok": True}
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
import datetime
router = APIRouter(tags=["devices"])
@router.post("/", response_model=schemas.Device)
def create_device(device: schemas.DeviceCreate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == device.diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db_device = models.Device(**device.model_dump())
db.add(db_device)
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(db_device)
return db_device
@router.patch("/{device_id}", response_model=schemas.Device)
def update_device(device_id: int, update: schemas.DeviceUpdate, db: Session = Depends(get_db)):
device = db.query(models.Device).filter(models.Device.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(device, key, value)
diagram = db.query(models.Diagram).filter(models.Diagram.id == device.diagram_id).first()
if diagram:
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(device)
return device
@router.delete("/{device_id}")
def delete_device(device_id: int, db: Session = Depends(get_db)):
device = db.query(models.Device).filter(models.Device.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
db.delete(device)
db.commit()
return {"ok": True}
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from .. import models, schemas
from ..database import get_db
import datetime
router = APIRouter(tags=["diagrams"])
@router.get("/", response_model=List[schemas.DiagramSummary])
def list_diagrams(db: Session = Depends(get_db)):
return db.query(models.Diagram).order_by(models.Diagram.updated_at.desc()).all()
@router.post("/", response_model=schemas.DiagramSummary)
def create_diagram(diagram: schemas.DiagramCreate, db: Session = Depends(get_db)):
db_diagram = models.Diagram(**diagram.model_dump())
db.add(db_diagram)
db.commit()
db.refresh(db_diagram)
return db_diagram
@router.get("/{diagram_id}", response_model=schemas.DiagramFull)
def get_diagram(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
@router.patch("/{diagram_id}", response_model=schemas.DiagramSummary)
def update_diagram(diagram_id: int, update: schemas.DiagramUpdate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(diagram, key, value)
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(diagram)
return diagram
@router.delete("/{diagram_id}")
def delete_diagram(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db.delete(diagram)
db.commit()
return {"ok": True}
+316
View File
@@ -0,0 +1,316 @@
import csv
import io
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
router = APIRouter(tags=["export"])
@router.get("/{diagram_id}/bom/csv")
def export_bom_csv(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["=== DEVICES ==="])
writer.writerow(["Reference", "Type", "Label", "Part Number", "Manufacturer", "Description", "Datasheet"])
for device in diagram.devices:
props = device.properties or {}
desc = props.get("description", "")
if device.device_type == "cable" and not desc:
desc = f"{props.get('conductorCount', len(props.get('conductors', [])))} conductors"
writer.writerow([
device.reference, device.device_type, device.label,
props.get("partNumber", ""), props.get("manufacturer", ""),
desc, props.get("datasheetUrl", ""),
])
writer.writerow([])
writer.writerow(["=== WIRES ==="])
writer.writerow(["ID", "Label", "From Device", "From Pin", "To Device", "To Pin",
"Primary Color", "Stripe Color", "Gauge", "Length", "Unit", "Shielded", "Notes"])
device_map = {d.id: d for d in diagram.devices}
for wire in diagram.wires:
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
writer.writerow([
wire.id, wire.label,
f"{fd.reference} ({fd.label})" if fd else "",
wire.from_pin,
f"{td.reference} ({td.label})" if td else "",
wire.to_pin,
wire.color_primary, wire.color_stripe or "",
wire.gauge, wire.length or "", wire.length_unit if wire.length else "",
"YES" if wire.shielded else "", wire.notes,
])
output.seek(0)
filename = f"{diagram.name.replace(' ', '_')}_BOM.csv"
return StreamingResponse(
io.BytesIO(output.getvalue().encode()),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{diagram_id}/assembly/txt")
def export_assembly_txt(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
device_map = {d.id: d for d in diagram.devices}
lines = [
f"ASSEMBLY INSTRUCTIONS: {diagram.name}",
"=" * 60, "",
]
if diagram.description:
lines += [f"Description: {diagram.description}", ""]
lines += ["STEP 1: PREPARE DEVICES", "-" * 40]
for i, d in enumerate(diagram.devices, 1):
props = d.properties or {}
lines.append(f" {i}. {d.reference or '?'}{d.label} "
f"(Type: {d.device_type}, P/N: {props.get('partNumber', 'N/A')})")
lines.append("")
# Group wires by which cable device (if any) they pass through
cable_devices = {d.id: d for d in diagram.devices if d.device_type == "cable"}
wire_cable_map = {} # wire_id -> cable_device_id
for wire in diagram.wires:
if wire.from_device_id in cable_devices or wire.to_device_id in cable_devices:
cable_id = wire.from_device_id if wire.from_device_id in cable_devices else wire.to_device_id
wire_cable_map[wire.id] = cable_id
lines += ["STEP 2: WIRE CONNECTIONS", "-" * 40]
step = 1
for wire in diagram.wires:
if wire.id in wire_cable_map:
continue # handled in cables step
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
from_str = f"{fd.reference or fd.label} Pin {wire.from_pin}" if fd else "unconnected"
to_str = f"{td.reference or td.label} Pin {wire.to_pin}" if td else "unconnected"
color_str = wire.color_primary
if wire.color_stripe:
color_str += f" / {wire.color_stripe} stripe"
length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD"
twisted = " [TWISTED PAIR]" if wire.twisted_pair else ""
shielded = " [SHIELDED]" if wire.shielded else ""
lines += [
f" {step}. {color_str} {wire.gauge} {length_str}{twisted}{shielded}",
f" From: {from_str}",
f" To: {to_str}",
]
if wire.label:
lines.append(f" Label: {wire.label}")
if wire.notes:
lines.append(f" Notes: {wire.notes}")
lines.append("")
step += 1
if cable_devices:
lines += ["", "STEP 3: CABLE ASSEMBLIES", "-" * 40]
for cable_dev in cable_devices.values():
props = cable_dev.properties or {}
conductors = props.get("conductors", [])
pn = props.get("partNumber", "N/A")
lines += [
f" Cable: {cable_dev.reference or '?'}{cable_dev.label} (P/N: {pn}, {len(conductors)} conductors)",
]
cable_wires = [w for w in diagram.wires if wire_cable_map.get(w.id) == cable_dev.id]
for wire in cable_wires:
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
other_dev = td if wire.from_device_id == cable_dev.id else fd
other_pin = wire.to_pin if wire.from_device_id == cable_dev.id else wire.from_pin
cable_pin = wire.from_pin if wire.from_device_id == cable_dev.id else wire.to_pin
length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD"
lines.append(
f" Pin {cable_pin} ({wire.color_primary}) → "
f"{other_dev.reference or other_dev.label if other_dev else '?'} Pin {other_pin} {length_str}"
)
lines.append("")
content = "\n".join(lines)
filename = f"{diagram.name.replace(' ', '_')}_Assembly.txt"
return StreamingResponse(
io.BytesIO(content.encode()),
media_type="text/plain",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{diagram_id}/formboard", response_class=HTMLResponse)
def export_formboard(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
device_map = {d.id: d for d in diagram.devices}
def to_mm(length, unit):
factors = {"mm": 1.0, "cm": 10.0, "in": 25.4, "ft": 304.8}
return length * factors.get(unit or "in", 25.4)
wires_with_length = []
wires_no_length = []
for wire in diagram.wires:
if wire.length and wire.length > 0:
mm = to_mm(wire.length, wire.length_unit)
wires_with_length.append((wire, mm))
else:
wires_no_length.append(wire)
wires_with_length.sort(key=lambda t: t[1], reverse=True)
def wire_label(wire):
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
frm = f"{fd.reference or fd.label}/{wire.from_pin}" if fd else "?"
to = f"{td.reference or td.label}/{wire.to_pin}" if td else "?"
return f"{frm}{to}" + (f" [{wire.label}]" if wire.label else "")
def stripe_style(wire):
if wire.color_stripe:
c1, c2 = wire.color_primary or "#cc0000", wire.color_stripe
return f"background: repeating-linear-gradient(45deg, {c1}, {c1} 4mm, {c2} 4mm, {c2} 8mm);"
return f"background: {wire.color_primary or '#cc0000'};"
rows_html = ""
for wire, mm in wires_with_length:
badges = ""
if wire.twisted_pair:
badges += '<span class="badge tw">TWISTED</span>'
if wire.shielded:
badges += '<span class="badge sh">SHIELDED</span>'
txt_color = "#000" if (wire.color_primary or "#000") in ("#FFD700","#DDDDDD","#FF8C00","#dddddd") else "#fff"
rows_html += f"""
<tr>
<td class="lbl">{wire_label(wire)}</td>
<td class="gauge">{wire.gauge or ""}</td>
<td class="len-val">{wire.length:.1f} {wire.length_unit or "in"} ({mm:.0f} mm)</td>
<td class="bar-cell">
<div class="wire-bar" style="width:{mm:.1f}mm;{stripe_style(wire)}color:{txt_color}">
<span class="bar-text">{mm:.0f} mm</span>
</div>
</td>
<td>{badges}</td>
</tr>"""
no_len_rows = ""
for wire in wires_no_length:
no_len_rows += f"<li>{wire_label(wire)}{wire.gauge or 'gauge?'}</li>"
no_len_section = ""
if wires_no_length:
no_len_section = f"""
<h2>Wires without length</h2>
<ul class="no-len">{no_len_rows}</ul>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Formboard — {diagram.name}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: sans-serif; font-size: 11pt; padding: 10mm; background: #fff; color: #111; }}
h1 {{ font-size: 16pt; margin-bottom: 4mm; }}
.meta {{ font-size: 9pt; color: #555; margin-bottom: 6mm; }}
.scale-bar-wrap {{ margin-bottom: 8mm; }}
.scale-bar {{ display: inline-block; width: 100mm; height: 5mm; background: #333; }}
.scale-bar-lbl {{ font-size: 9pt; color: #555; margin-left: 3mm; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 8mm; }}
th {{ text-align: left; font-size: 9pt; color: #555; border-bottom: 0.5mm solid #ccc; padding: 1mm 2mm; }}
td {{ padding: 1.5mm 2mm; vertical-align: middle; border-bottom: 0.2mm solid #e8e8e8; }}
.lbl {{ font-size: 9pt; max-width: 60mm; word-break: break-all; }}
.gauge {{ font-size: 9pt; white-space: nowrap; }}
.len-val {{ font-size: 9pt; white-space: nowrap; }}
.bar-cell {{ width: 99%; }}
.wire-bar {{
height: 5mm; min-width: 5mm; border-radius: 1mm;
display: flex; align-items: center; padding: 0 2mm;
print-color-adjust: exact; -webkit-print-color-adjust: exact;
}}
.bar-text {{ font-size: 7pt; white-space: nowrap; }}
.badge {{ font-size: 7pt; padding: 0.5mm 1.5mm; border-radius: 1mm; margin-left: 1mm; white-space: nowrap; }}
.tw {{ background: #d0e8ff; color: #003; }}
.sh {{ background: #e8e8e8; color: #333; }}
.no-len {{ font-size: 9pt; padding-left: 5mm; margin-bottom: 6mm; }}
.no-len li {{ margin-bottom: 1mm; }}
h2 {{ font-size: 12pt; margin: 6mm 0 3mm; }}
@media print {{
body {{ padding: 5mm; }}
.no-print {{ display: none; }}
}}
</style>
</head>
<body>
<h1>📐 Formboard — {diagram.name}</h1>
<p class="meta">Wire bars are printed at true 1:1 scale. Print at 100% (no scaling) for accurate lengths.</p>
<div class="scale-bar-wrap no-print">
<strong>100 mm scale reference:</strong><br>
<div class="scale-bar"></div><span class="scale-bar-lbl">← this bar is exactly 100 mm when printed at 100%</span>
</div>
<table>
<thead><tr>
<th>Wire</th><th>Gauge</th><th>Length</th><th>Scale (1:1)</th><th>Flags</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
{no_len_section}
<p class="meta no-print" style="margin-top:8mm">Print → More settings → Scale: 100% → No headers/footers</p>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/{diagram_id}/json")
def export_json(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
data = {
"id": diagram.id,
"name": diagram.name,
"description": diagram.description,
"devices": [
{
"id": d.id, "device_type": d.device_type, "label": d.label,
"reference": d.reference, "x": d.x, "y": d.y,
"width": d.width, "height": d.height,
"properties": d.properties, "pins": d.pins,
}
for d in diagram.devices
],
"wires": [
{
"id": w.id, "label": w.label,
"from_device_id": w.from_device_id, "from_pin": w.from_pin,
"to_device_id": w.to_device_id, "to_pin": w.to_pin,
"color_primary": w.color_primary, "color_stripe": w.color_stripe,
"gauge": w.gauge, "length": w.length, "length_unit": w.length_unit,
"notes": w.notes,
}
for w in diagram.wires
],
}
filename = f"{diagram.name.replace(' ', '_')}.json"
return StreamingResponse(
io.BytesIO(json.dumps(data, indent=2).encode()),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+109
View File
@@ -0,0 +1,109 @@
import os
import time
from typing import Tuple
import httpx
from fastapi import APIRouter, HTTPException, Query
router = APIRouter(tags=["octopart"])
NEXAR_TOKEN_URL = "https://identity.nexar.com/connect/token"
NEXAR_API_URL = "https://api.nexar.com/graphql"
_token_cache: dict = {"token": None, "expires_at": 0.0}
_SEARCH_GQL = """
query PartSearch($q: String!, $limit: Int!) {
supSearchMpn(q: $q, limit: $limit) {
results {
part {
mpn
manufacturer { name }
shortDescription
bestDatasheet { url }
}
}
}
}
"""
def _credentials() -> Tuple[str, str]:
return (
os.getenv("NEXAR_CLIENT_ID", ""),
os.getenv("NEXAR_CLIENT_SECRET", ""),
)
async def _get_token() -> str:
client_id, client_secret = _credentials()
if not client_id or not client_secret:
raise HTTPException(
status_code=503,
detail="Nexar/Octopart credentials not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET environment variables",
)
now = time.time()
if _token_cache["token"] and now < _token_cache["expires_at"] - 60:
return _token_cache["token"] # type: ignore[return-value]
async with httpx.AsyncClient() as client:
resp = await client.post(
NEXAR_TOKEN_URL,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
timeout=10,
)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Nexar auth failed: {resp.text[:200]}")
body = resp.json()
_token_cache["token"] = body["access_token"]
_token_cache["expires_at"] = now + body.get("expires_in", 3600)
return _token_cache["token"] # type: ignore[return-value]
@router.get("/status")
async def octopart_status():
client_id, client_secret = _credentials()
return {"configured": bool(client_id and client_secret)}
@router.get("/search")
async def octopart_search(
q: str = Query(..., min_length=1),
limit: int = Query(8, ge=1, le=20),
):
token = await _get_token()
async with httpx.AsyncClient() as client:
resp = await client.post(
NEXAR_API_URL,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"query": _SEARCH_GQL, "variables": {"q": q, "limit": limit}},
timeout=12,
)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Nexar API error {resp.status_code}")
body = resp.json()
if "errors" in body:
raise HTTPException(status_code=502, detail=str(body["errors"])[:300])
results = body.get("data", {}).get("supSearchMpn", {}).get("results", [])
out = []
for r in results:
part = r.get("part")
if not part:
continue
out.append({
"mpn": part.get("mpn", ""),
"manufacturer": (part.get("manufacturer") or {}).get("name", ""),
"description": part.get("shortDescription") or "",
"datasheet_url": (part.get("bestDatasheet") or {}).get("url"),
})
return out
+51
View File
@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["sheets"])
@router.post("/", response_model=schemas.SheetOut)
def create_sheet(sheet: schemas.SheetCreate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == sheet.diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db_sheet = models.Sheet(**sheet.model_dump())
db.add(db_sheet)
db.commit()
db.refresh(db_sheet)
return db_sheet
@router.get("/{sheet_id}", response_model=schemas.SheetOut)
def get_sheet(sheet_id: int, db: Session = Depends(get_db)):
sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=404, detail="Sheet not found")
return sheet
@router.patch("/{sheet_id}", response_model=schemas.SheetOut)
def update_sheet(sheet_id: int, update: schemas.SheetUpdate, db: Session = Depends(get_db)):
sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=404, detail="Sheet not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(sheet, key, value)
db.commit()
db.refresh(sheet)
return sheet
@router.delete("/{sheet_id}")
def delete_sheet(sheet_id: int, db: Session = Depends(get_db)):
sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=404, detail="Sheet not found")
sheet_count = db.query(models.Sheet).filter(models.Sheet.diagram_id == sheet.diagram_id).count()
if sheet_count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last sheet")
db.delete(sheet)
db.commit()
return {"ok": True}
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
import datetime
router = APIRouter(tags=["wires"])
@router.post("/", response_model=schemas.Wire)
def create_wire(wire: schemas.WireCreate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == wire.diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db_wire = models.Wire(**wire.model_dump())
db.add(db_wire)
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(db_wire)
return db_wire
@router.patch("/{wire_id}", response_model=schemas.Wire)
def update_wire(wire_id: int, update: schemas.WireUpdate, db: Session = Depends(get_db)):
wire = db.query(models.Wire).filter(models.Wire.id == wire_id).first()
if not wire:
raise HTTPException(status_code=404, detail="Wire not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(wire, key, value)
diagram = db.query(models.Diagram).filter(models.Diagram.id == wire.diagram_id).first()
if diagram:
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(wire)
return wire
@router.delete("/{wire_id}")
def delete_wire(wire_id: int, db: Session = Depends(get_db)):
wire = db.query(models.Wire).filter(models.Wire.id == wire_id).first()
if not wire:
raise HTTPException(status_code=404, detail="Wire not found")
db.delete(wire)
db.commit()
return {"ok": True}