b3c3e2a3b2
- backend/app: FastAPI API wrapping the CAD skill modules - upload -> job -> poll -> model / BOM / artifacts -> geometry query - SQLite via SQLModel (Model, Job, BomRow, QueryLog) - ThreadPoolExecutor worker, serialized, with live stage updates - docker-compose.yml: dev server (mounts source, --reload) on :8000 - api-test.sh: end-to-end live validation script - requirements.txt: add fastapi, uvicorn, python-multipart, sqlmodel - external_diagram.py: port active-area detection OCC.Core -> OCP - .gitignore, PHASE1.md Validated live: MR16 round-trip passes (28 BOM rows, 12 artifacts, bounding-box query, xlsx download; active-area detection working). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.1 KiB
Python
158 lines
5.1 KiB
Python
"""STEP Parser API — Phase 1.
|
|
|
|
Endpoints:
|
|
GET /api/health
|
|
POST /api/upload multipart STEP upload -> creates model + job
|
|
GET /api/jobs/{job_id} job status / stage / artifacts
|
|
GET /api/models list models
|
|
GET /api/models/{id} model metadata + BOM + artifacts
|
|
GET /api/models/{id}/artifacts/{name} download a generated file
|
|
POST /api/models/{id}/query run a geometry query
|
|
"""
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from sqlmodel import Session, select
|
|
|
|
from . import processing
|
|
from .config import ALLOWED_SUFFIXES, MODELS_DIR
|
|
from .db import get_session, init_db
|
|
from .models import BomRow, Job, Model, QueryLog
|
|
from .worker import submit_job
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
log = logging.getLogger("step_parser.api")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
log.info("DB initialized")
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="STEP Parser API", version="0.1.0", lifespan=lifespan)
|
|
|
|
# Dev-permissive CORS; tighten to the Unraid frontend origin before it leaves the LAN.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
class QueryRequest(BaseModel):
|
|
query: str
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/api/upload")
|
|
async def upload(
|
|
file: UploadFile = File(...),
|
|
thumbnails: bool = Form(True),
|
|
bom: bool = Form(True),
|
|
diagram: bool = Form(False),
|
|
translate: bool = Form(False),
|
|
diagram_mode: str = Form("enclosure_only"),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
safe_name = Path(file.filename or "").name
|
|
suffix = Path(safe_name).suffix.lower()
|
|
if suffix not in ALLOWED_SUFFIXES:
|
|
raise HTTPException(400, f"Unsupported file type '{suffix}'. Expected .step or .stp")
|
|
|
|
stem = Path(safe_name).stem
|
|
model = Model(name=stem, original_filename=safe_name, stem=stem)
|
|
session.add(model)
|
|
session.commit()
|
|
session.refresh(model)
|
|
|
|
out_dir = MODELS_DIR / str(model.id)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
(out_dir / safe_name).write_bytes(await file.read())
|
|
|
|
options = {
|
|
"thumbnails": thumbnails, "bom": bom, "diagram": diagram,
|
|
"translate": translate, "diagram_mode": diagram_mode,
|
|
}
|
|
job = Job(model_id=model.id, status="pending", options=json.dumps(options))
|
|
session.add(job)
|
|
session.commit()
|
|
session.refresh(job)
|
|
|
|
submit_job(job.id)
|
|
return {"model_id": model.id, "job_id": job.id, "status": job.status}
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}")
|
|
def get_job(job_id: int, session: Session = Depends(get_session)):
|
|
job = session.get(Job, job_id)
|
|
if job is None:
|
|
raise HTTPException(404, "job not found")
|
|
data = job.model_dump()
|
|
data["artifacts"] = json.loads(job.artifacts) if job.artifacts else []
|
|
data["options"] = json.loads(job.options) if job.options else {}
|
|
return data
|
|
|
|
|
|
@app.get("/api/models")
|
|
def list_models(session: Session = Depends(get_session)):
|
|
return session.exec(select(Model).order_by(Model.id.desc())).all()
|
|
|
|
|
|
@app.get("/api/models/{model_id}")
|
|
def get_model(model_id: int, session: Session = Depends(get_session)):
|
|
model = session.get(Model, model_id)
|
|
if model is None:
|
|
raise HTTPException(404, "model not found")
|
|
bom = session.exec(
|
|
select(BomRow).where(BomRow.model_id == model_id).order_by(BomRow.level, BomRow.id)
|
|
).all()
|
|
latest = session.exec(
|
|
select(Job).where(Job.model_id == model_id).order_by(Job.id.desc())
|
|
).first()
|
|
artifacts = json.loads(latest.artifacts) if (latest and latest.artifacts) else []
|
|
return {
|
|
"model": model,
|
|
"bom": bom,
|
|
"artifacts": artifacts,
|
|
"latest_job": {"id": latest.id, "status": latest.status, "stage": latest.stage} if latest else None,
|
|
}
|
|
|
|
|
|
@app.get("/api/models/{model_id}/artifacts/{name}")
|
|
def get_artifact(model_id: int, name: str):
|
|
safe = Path(name).name # block path traversal
|
|
path = MODELS_DIR / str(model_id) / safe
|
|
if not path.is_file():
|
|
raise HTTPException(404, "artifact not found")
|
|
return FileResponse(path, filename=safe)
|
|
|
|
|
|
@app.post("/api/models/{model_id}/query")
|
|
def query_model(model_id: int, req: QueryRequest, session: Session = Depends(get_session)):
|
|
model = session.get(Model, model_id)
|
|
if model is None:
|
|
raise HTTPException(404, "model not found")
|
|
step_path = MODELS_DIR / str(model_id) / model.original_filename
|
|
if not step_path.is_file():
|
|
raise HTTPException(404, "model source file missing")
|
|
try:
|
|
result = processing.run_query(step_path, req.query)
|
|
except Exception as e: # noqa: BLE001
|
|
raise HTTPException(500, f"query failed: {type(e).__name__}: {e}")
|
|
session.add(QueryLog(model_id=model_id, query=req.query, result=result))
|
|
session.commit()
|
|
return {"query": req.query, "result": result}
|