Skip to content

Commit 1472300

Browse files
authored
Pr 521 (#524)
* Align CNC export endpoint with versioned exports API * Fix CNC export auth tests and fallback nesting engine * Fix duplicated orders router include * Refine CNC export artifact helpers * Reuse backend Order model and harden CNC fallback
1 parent 5014d11 commit 1472300

File tree

15 files changed

+329
-137
lines changed

15 files changed

+329
-137
lines changed

backend/api/exports.py

Lines changed: 108 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import logging
66
from datetime import datetime, timezone
77
from typing import Dict
8-
98
from fastapi import APIRouter, Depends, HTTPException
109
from sqlalchemy.orm import Session
1110

@@ -23,7 +22,7 @@
2322
)
2423
from api.security import require_write_token
2524
from api.storage_dependencies import get_storage_client
26-
from backend.services.storage import StorageClient
25+
from backend.services.storage import StorageClient, StoredObject
2726
from services.cnc_service import CNCService
2827

2928
logger = logging.getLogger(__name__)
@@ -32,8 +31,95 @@
3231
tags=["exports"],
3332
dependencies=[Depends(require_write_token)],
3433
)
34+
legacy_router = APIRouter(
35+
prefix="/api/cnc",
36+
tags=["cnc"],
37+
dependencies=[Depends(require_write_token)],
38+
)
3539
_cnc_service = CNCService()
3640

41+
__all__ = ["router", "legacy_router"]
42+
43+
44+
def _to_stored_artifact(obj: StoredObject) -> StoredArtifact:
45+
return StoredArtifact(
46+
bucket=obj.bucket,
47+
key=obj.key,
48+
url=obj.url,
49+
content_type=obj.content_type,
50+
)
51+
52+
53+
def _export_cnc(
54+
payload: CNCExportRequest,
55+
db: Session,
56+
storage: StorageClient,
57+
settings: Settings,
58+
) -> CNCExportResponse:
59+
module_rows = db.query(models.Module).all()
60+
material_rows = db.query(models.Material).all()
61+
62+
modules_by_id: Dict[str, ModuleDTO] = {
63+
module.id: ModuleDTO.model_validate(module) for module in module_rows
64+
}
65+
materials_by_id: Dict[str, MaterialDTO] = {
66+
material.id: MaterialDTO.model_validate(material)
67+
for material in material_rows
68+
}
69+
70+
artifacts = _cnc_service.export(
71+
request=payload,
72+
modules_by_id=modules_by_id,
73+
materials_by_id=materials_by_id,
74+
)
75+
76+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
77+
prefix = settings.cnc_exports_prefix.strip("/")
78+
configuration_segment = payload.configuration_id.replace(" ", "-")
79+
base_path_parts = [
80+
segment for segment in (prefix, configuration_segment, timestamp) if segment
81+
]
82+
base_path = "/".join(base_path_parts) if base_path_parts else timestamp
83+
84+
csv_key = f"{base_path}/cut-list.csv"
85+
svg_key = f"{base_path}/nesting.svg"
86+
87+
artifacts.cut_list_csv.seek(0)
88+
csv_object = storage.upload_fileobj(
89+
artifacts.cut_list_csv,
90+
csv_key,
91+
content_type="text/csv",
92+
)
93+
94+
artifacts.nesting_svg.seek(0)
95+
svg_object = storage.upload_fileobj(
96+
artifacts.nesting_svg,
97+
svg_key,
98+
content_type="image/svg+xml",
99+
)
100+
101+
placements = [
102+
CNCNestingPlacement(
103+
panel_id=placement.panel_id,
104+
x=placement.x,
105+
y=placement.y,
106+
rotation=placement.rotation,
107+
sheet_index=placement.sheet_index,
108+
)
109+
for placement in artifacts.nesting.placements
110+
]
111+
112+
return CNCExportResponse(
113+
panels=artifacts.panels,
114+
cut_list=_to_stored_artifact(csv_object),
115+
nesting=CNCNestingMetadata(
116+
sheet_width=artifacts.nesting.sheet_width,
117+
sheet_height=artifacts.nesting.sheet_height,
118+
placements=placements,
119+
svg=_to_stored_artifact(svg_object),
120+
),
121+
)
122+
37123

38124
@router.post("/cnc", response_model=CNCExportResponse)
39125
def export_cnc(
@@ -45,81 +131,29 @@ def export_cnc(
45131
"""Generate CNC export artifacts and store them via the configured storage client."""
46132

47133
try:
48-
module_rows = db.query(models.Module).all()
49-
material_rows = db.query(models.Material).all()
50-
51-
modules_by_id: Dict[str, ModuleDTO] = {
52-
module.id: ModuleDTO.model_validate(module) for module in module_rows
53-
}
54-
materials_by_id: Dict[str, MaterialDTO] = {
55-
material.id: MaterialDTO.model_validate(material)
56-
for material in material_rows
57-
}
58-
59-
artifacts = _cnc_service.export(
60-
request=payload,
61-
modules_by_id=modules_by_id,
62-
materials_by_id=materials_by_id,
63-
)
64-
65-
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
66-
prefix = settings.cnc_exports_prefix.strip("/")
67-
configuration_segment = payload.configuration_id.replace(" ", "-")
68-
base_path_parts = [
69-
segment for segment in (prefix, configuration_segment, timestamp) if segment
70-
]
71-
base_path = "/".join(base_path_parts) if base_path_parts else timestamp
72-
73-
csv_key = f"{base_path}/cut-list.csv"
74-
svg_key = f"{base_path}/nesting.svg"
75-
76-
artifacts.cut_list_csv.seek(0)
77-
csv_object = storage.upload_fileobj(
78-
artifacts.cut_list_csv,
79-
csv_key,
80-
content_type="text/csv",
81-
)
134+
return _export_cnc(payload, db, storage, settings)
135+
except HTTPException:
136+
raise
137+
except Exception as exc: # noqa: BLE001
138+
logger.exception("Error exporting CNC")
139+
raise HTTPException(status_code=500, detail=str(exc)) from exc
82140

83-
artifacts.nesting_svg.seek(0)
84-
svg_object = storage.upload_fileobj(
85-
artifacts.nesting_svg,
86-
svg_key,
87-
content_type="image/svg+xml",
88-
)
89141

90-
placements = [
91-
CNCNestingPlacement(
92-
panel_id=placement.panel_id,
93-
x=placement.x,
94-
y=placement.y,
95-
rotation=placement.rotation,
96-
sheet_index=placement.sheet_index,
97-
)
98-
for placement in artifacts.nesting.placements
99-
]
100-
101-
return CNCExportResponse(
102-
panels=artifacts.panels,
103-
cut_list=StoredArtifact(
104-
bucket=csv_object.bucket,
105-
key=csv_object.key,
106-
url=csv_object.url,
107-
content_type=csv_object.content_type,
108-
),
109-
nesting=CNCNestingMetadata(
110-
sheet_width=artifacts.nesting.sheet_width,
111-
sheet_height=artifacts.nesting.sheet_height,
112-
placements=placements,
113-
svg=StoredArtifact(
114-
bucket=svg_object.bucket,
115-
key=svg_object.key,
116-
url=svg_object.url,
117-
content_type=svg_object.content_type,
118-
),
119-
),
120-
)
142+
@legacy_router.post(
143+
"/export",
144+
response_model=CNCExportResponse,
145+
include_in_schema=False,
146+
)
147+
def export_cnc_legacy(
148+
payload: CNCExportRequest,
149+
db: Session = Depends(get_db),
150+
storage: StorageClient = Depends(get_storage_client),
151+
settings: Settings = Depends(get_settings),
152+
) -> CNCExportResponse:
153+
try:
154+
return _export_cnc(payload, db, storage, settings)
121155
except HTTPException:
122156
raise
123157
except Exception as exc: # noqa: BLE001
124-
logger.exception("Error exporting CNC")
158+
logger.exception("Error exporting CNC (legacy path)")
125159
raise HTTPException(status_code=500, detail=str(exc)) from exc

backend/api/main.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,11 @@
2424
record_http_request,
2525
suppress_prometheus_http_metrics,
2626
)
27-
from api.orders import router as orders_router
28-
from api.routes import router as api_router
27+
from api.exports import legacy_router as legacy_exports_router
2928
from api.exports import router as exports_router
29+
from api.routes import router as api_router
3030
from api.routes_catalog import router as catalog_router
3131
from api.routes_catalog import router_mesh as catalog_mesh_router
32-
from api.exports import router as exports_router
3332
from api.routes_designs import router as design_router
3433
from api.routes_materials import router as materials_router
3534
from api.routes_modules import router as modules_router
@@ -125,13 +124,13 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override]
125124

126125
# Include API router
127126
app.include_router(api_router)
128-
app.include_router(orders_router)
129127
app.include_router(catalog_router)
130128
app.include_router(catalog_mesh_router)
131129
app.include_router(materials_router)
132130
app.include_router(modules_router)
133131
app.include_router(pricing_router)
134132
app.include_router(exports_router)
133+
app.include_router(legacy_exports_router)
135134
app.include_router(design_router)
136135
if sync_router is not None:
137136
app.include_router(sync_router)

backend/api/models.py

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
from datetime import datetime
1414
from typing import Any
1515

16-
from sqlalchemy import Float, ForeignKey, String, TIMESTAMP, UniqueConstraint, func
16+
from sqlalchemy import Float, String, TIMESTAMP, UniqueConstraint, func
1717
from sqlalchemy.orm import Mapped, mapped_column
1818

19-
from models import (
19+
from models.base import Base
20+
from models.order import Order
21+
from models.pricing import (
2022
JSON_COMPAT,
2123
Finish,
2224
HardwareKit,
@@ -28,7 +30,6 @@
2830
QuoteRun,
2931
QuoteRunTier,
3032
)
31-
from models.base import Base
3233

3334
__all__ = [
3435
"Material",
@@ -73,31 +74,6 @@ class ProjectQuote(Base):
7374
)
7475

7576

76-
class Order(Base):
77-
"""Sales order record persisted after a quote is accepted."""
78-
79-
__tablename__ = "orders"
80-
81-
id: Mapped[str] = mapped_column(
82-
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
83-
)
84-
quote_id: Mapped[str] = mapped_column(
85-
String(36), ForeignKey("project_quotes.id"), nullable=False
86-
)
87-
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
88-
order_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
89-
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
90-
total_amount: Mapped[float] = mapped_column(Float, nullable=False)
91-
customer_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
92-
93-
created_at: Mapped[datetime] = mapped_column(
94-
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
95-
)
96-
updated_at: Mapped[datetime] = mapped_column(
97-
TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now()
98-
)
99-
100-
10177
class SyncEvent(Base):
10278
"""Webhook deduplication store to prevent replay processing."""
10379

backend/api/orders.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from datetime import datetime
77
from typing import List
88

9-
from fastapi import APIRouter, Depends, HTTPException, status
9+
from fastapi import APIRouter, Depends, HTTPException, Response, status
1010
from pydantic import BaseModel, ConfigDict, Field
1111
from sqlalchemy import select
1212
from sqlalchemy.exc import SQLAlchemyError
@@ -170,7 +170,7 @@ def update_order(
170170
return order
171171

172172

173-
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
173+
@router.delete("/{order_id}")
174174
def delete_order(
175175
order_id: str,
176176
db: Session = Depends(get_db),
@@ -190,3 +190,4 @@ def delete_order(
190190
detail="failed to delete order",
191191
) from exc
192192

193+
return Response(status_code=status.HTTP_204_NO_CONTENT)

backend/models/order.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@ class Order(Base):
6060
Index("ix_orders_quote_id", "quote_id"),
6161
)
6262

63-
@property
64-
def metadata(self) -> dict[str, Any] | None: # pragma: no cover - passthrough
63+
def metadata_dict(self) -> dict[str, Any] | None:
6564
"""Return additional metadata stored with the order."""
6665

6766
return self.metadata_
6867

69-
@metadata.setter
70-
def metadata(self, value: dict[str, Any] | None) -> None:
68+
def set_metadata_dict(self, value: dict[str, Any] | None) -> None:
69+
"""Update additional metadata stored with the order."""
70+
7171
self.metadata_ = value

0 commit comments

Comments
 (0)