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 += 'TWISTED' if wire.shielded: badges += 'SHIELDED' txt_color = "#000" if (wire.color_primary or "#000") in ("#FFD700","#DDDDDD","#FF8C00","#dddddd") else "#fff" rows_html += f""" {wire_label(wire)} {wire.gauge or ""} {wire.length:.1f} {wire.length_unit or "in"} ({mm:.0f} mm)
{mm:.0f} mm
{badges} """ no_len_rows = "" for wire in wires_no_length: no_len_rows += f"
  • {wire_label(wire)} — {wire.gauge or 'gauge?'}
  • " no_len_section = "" if wires_no_length: no_len_section = f"""

    Wires without length

    """ html = f""" Formboard — {diagram.name}

    📐 Formboard — {diagram.name}

    Wire bars are printed at true 1:1 scale. Print at 100% (no scaling) for accurate lengths.

    100 mm scale reference:
    ← this bar is exactly 100 mm when printed at 100%
    {rows_html}
    WireGaugeLengthScale (1:1)Flags
    {no_len_section}

    Print → More settings → Scale: 100% → No headers/footers

    """ 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}"}, )