"""Slabs API: create calibrated draft from multipart upload. Per OPENAPI.md §slabs: - POST /api/v1/slabs accepts multipart/form-data with: - data: JSON SlabCreate payload - files[]: original image files - meta[]: photo metadata JSON objects - Returns 201 with status: calibrated and server_rev - Server stores client-sent numbers only; no server-side recompute. Per DATA-MODEL.md: - slabs table: client-generated UUID, sku unique, status calibrated - slab_photos table: slab_id FK, kind inventory, role, seq, original_path - server_rev = 1 on create """ import logging import tempfile import uuid from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator from sqlalchemy import text from app.config import Settings from app.db.engine import get_session from app.db.models import Slab, SlabPhoto, SlabStatus logger = logging.getLogger(__name__) router = APIRouter() # --------------------------------------------------------------------------- # Pydantic models # --------------------------------------------------------------------------- class PhotoMeta(BaseModel): """Photo metadata from the multipart request.""" kind: str = Field(default="inventory") role: str | None = None seq: int = 0 @field_validator("kind") @classmethod def validate_kind(cls, v: str) -> str: if v != "inventory": raise ValueError("kind must be 'inventory'") return v @field_validator("role") @classmethod def validate_role(cls, v: str | None) -> str | None: if v is not None and v not in ("topdown", "extra"): raise ValueError("role must be 'topdown' or 'extra'") return v class SlabCreateData(BaseModel): """Client-sent slab data (the `data` multipart field). Mirrors the OPENAPI.md SlabCreate schema. All numeric fields are client-computed; the server stores them as-is without recomputation. """ id: str = Field(description="Client-generated UUID") sku: str = Field(min_length=3, max_length=24, pattern=r"^[A-Z0-9-]+$") length_in: float = Field(ge=0) thickness_in: float = Field(ge=0) sqft: float | None = Field(default=None, ge=0) bdft: float | None = Field(default=None, ge=0) width_min_in: float | None = Field(default=None, ge=0) width_max_in: float | None = Field(default=None, ge=0) width_avg_in: float | None = Field(default=None, ge=0) species_id: int | None = None wood_category_ids: list[int] | None = None edge_type_term_id: int | None = None figure_term_ids: list[int] | None = None grade_term_ids: list[int] | None = None thickness_term_id: int | None = None moisture_term_id: int | None = None fig_tag_ids: list[int] | None = None feat_tag_ids: list[int] | None = None price_per_bdft: float | None = Field(default=None, ge=0) price: float = Field(ge=0) price_source: str | None = Field(default=None, pattern=r"^(recommendation|override)$") title: str | None = None short_title: str | None = None description: str | None = None content_source: str | None = Field(default=None, pattern=r"^(manual|llm)$") client_rev: int = Field(default=1, ge=1) class PhotoRead(BaseModel): """Photo read model returned in slab responses.""" id: str kind: str = "inventory" role: str | None = None seq: int = 0 original_url: str processed_url: str | None = None class SlabRead(BaseModel): """Slab read model — all fields from create plus server state.""" id: str sku: str status: str length_in: float thickness_in: float sqft: float | None = None bdft: float | None = None width_min_in: float | None = None width_max_in: float | None = None width_avg_in: float | None = None species_id: int | None = None wood_category_ids: list[int] | None = None edge_type_term_id: int | None = None figure_term_ids: list[int] | None = None grade_term_ids: list[int] | None = None thickness_term_id: int | None = None moisture_term_id: int | None = None fig_tag_ids: list[int] | None = None feat_tag_ids: list[int] | None = None price_per_bdft: float | None = None price: float price_source: str | None = None title: str | None = None short_title: str | None = None description: str | None = None content_source: str | None = None client_rev: int server_rev: int = 1 created_at: str updated_at: str inference_thread_id: str | None = None species_confidence: float | None = None woo_product_id: int | None = None published_at: str | None = None photos: list[PhotoRead] = [] inference_status: str | None = None inference_error: str | None = None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _IMAGES_DIR = Path("/data/images") def _get_images_dir() -> Path: """Return the images storage directory, creating it if needed. For tests, falls back to a temp directory. """ try: _IMAGES_DIR.mkdir(parents=True, exist_ok=True) return _IMAGES_DIR except (PermissionError, OSError): # Fallback: use temp directory (e.g., in tests) return Path(tempfile.mkdtemp(prefix="slab_")) def _save_photo_file(photo_id: str, file_bytes: bytes, filename: str) -> str: """Save an uploaded photo file to disk and return the relative path. Stores as images/{photo_id}/{filename}. In test environments, uses a temp directory. """ base_dir = _get_images_dir() photo_dir = base_dir / photo_id photo_dir.mkdir(parents=True, exist_ok=True) file_path = photo_dir / filename file_path.write_bytes(file_bytes) return str(f"images/{photo_id}/{filename}") def _slab_read_from_model(slab: Slab, photos: list[SlabPhoto] | None = None) -> SlabRead: """Convert a Slab ORM model to SlabRead response.""" photos_read = [] if photos: for p in photos: original_url = ( f"/api/v1/slabs/{slab.id}/photos/{p.id}/original" if p.original_path else None ) photos_read.append( PhotoRead( id=p.id, kind=p.kind, role=p.role, seq=p.seq, original_url=original_url or "", processed_url=p.processed_path, ) ) return SlabRead( id=slab.id, sku=slab.sku, status=slab.status.value, length_in=slab.length_in, thickness_in=slab.thickness_in, sqft=slab.sqft, bdft=slab.bdft, width_min_in=slab.width_min_in, width_max_in=slab.width_max_in, width_avg_in=slab.width_avg_in, species_id=slab.species_id, wood_category_ids=slab.wood_category_ids, edge_type_term_id=slab.edge_type_term_id, figure_term_ids=slab.figure_term_ids, grade_term_ids=slab.grade_term_ids, thickness_term_id=slab.thickness_term_id, moisture_term_id=slab.moisture_term_id, fig_tag_ids=slab.fig_tag_ids, feat_tag_ids=slab.feat_tag_ids, price_per_bdft=slab.price_per_bdft, price=slab.price, price_source=slab.price_source, title=slab.title, short_title=slab.short_title, description=slab.description, content_source=slab.content_source, client_rev=slab.client_rev, server_rev=slab.server_rev, created_at=slab.created_at, updated_at=slab.updated_at, inference_thread_id=slab.inference_thread_id, photos=photos_read, inference_status=slab.inference_thread_id, ) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.post("/api/v1/slabs", status_code=201) async def create_slab(request: Request) -> JSONResponse: """Create a calibrated draft from a multipart upload. The request body must be multipart/form-data with these fields: - `data` — JSON-encoded SlabCreate payload - `files[]` — original image files (1–5 photos) - `meta[]` — JSON-encoded photo metadata objects Returns 201 with the created slab (status: calibrated, server_rev: 1). Server stores client-sent numbers only; no server-side recompute. Per AGENTS.md §3: client is authoritative for deterministic math; server stores client-sent numbers. """ # Parse multipart form data form = await request.form() # --- Extract and validate `data` field --- data_json = form.get("data") if not data_json: raise HTTPException( status_code=400, detail={ "code": "validation", "message": "Missing 'data' field in multipart upload.", }, ) try: create_data = SlabCreateData.model_validate_json(data_json) except Exception as e: raise HTTPException( status_code=400, detail={ "code": "validation", "message": f"Invalid data JSON: {e}", }, ) # --- Extract `files[]` and `meta[]` --- files: list[request.file_uploads] = form.getlist("files[]") metas: list[str] = form.getlist("meta[]") # Require at least one photo if not files: raise HTTPException( status_code=400, detail={ "code": "validation", "message": "At least one photo file is required.", }, ) # Pair files with metadata; extras get default meta if len(metas) < len(files): # Pad with default meta for files without explicit metadata default_meta = PhotoMeta(kind="inventory", role=None, seq=0) metas = list(metas) + [default_meta.model_dump_json()] * (len(files) - len(metas)) if len(files) != len(metas): raise HTTPException( status_code=400, detail={ "code": "validation", "message": "files[] and meta[] counts must match.", }, ) # --- Validate meta objects and resolve photo files --- photo_metas = [] for i, meta_json in enumerate(metas): try: meta = PhotoMeta.model_validate_json(meta_json) except Exception as e: raise HTTPException( status_code=400, detail={ "code": "validation", "message": f"Invalid meta[{i}] JSON: {e}", }, ) photo_metas.append(meta) # --- Database: create slab and photos --- session = await get_session() now = datetime.now(timezone.utc).isoformat() try: # Check for duplicate SKU existing = await session.execute( text("SELECT id FROM slabs WHERE sku = :sku"), {"sku": create_data.sku} ) if existing.fetchone(): raise HTTPException( status_code=409, detail={ "code": "duplicate_sku", "message": f"SKU '{create_data.sku}' already exists.", "actions": ["edit_sku", "open_existing"], }, ) # Create slab record — status starts as calibrated slab = Slab( id=create_data.id, sku=create_data.sku, status=SlabStatus.CALIBRATED, length_in=create_data.length_in, thickness_in=create_data.thickness_in, sqft=create_data.sqft, bdft=create_data.bdft, width_min_in=create_data.width_min_in, width_max_in=create_data.width_max_in, width_avg_in=create_data.width_avg_in, species_id=create_data.species_id, wood_category_ids=create_data.wood_category_ids, edge_type_term_id=create_data.edge_type_term_id, figure_term_ids=create_data.figure_term_ids, grade_term_ids=create_data.grade_term_ids, thickness_term_id=create_data.thickness_term_id, moisture_term_id=create_data.moisture_term_id, fig_tag_ids=create_data.fig_tag_ids, feat_tag_ids=create_data.feat_tag_ids, price_per_bdft=create_data.price_per_bdft, price=create_data.price, price_source=create_data.price_source, title=create_data.title, short_title=create_data.short_title, description=create_data.description, content_source=create_data.content_source, client_rev=create_data.client_rev, server_rev=1, created_at=now, updated_at=now, inference_thread_id=None, ) session.add(slab) # Create photo records and store files photos = [] for file_upload, meta in zip(files, photo_metas): file_bytes = await file_upload.read() filename = file_upload.filename or f"photo_{meta.seq}.jpg" photo_id = str(uuid.uuid4()) original_path = _save_photo_file(photo_id, file_bytes, filename) photo = SlabPhoto( id=photo_id, slab_id=slab.id, kind=meta.kind, role=meta.role, seq=meta.seq, original_path=original_path, processed_path=None, created_at=now, ) session.add(photo) photos.append(photo) await session.commit() # Build response result = _slab_read_from_model(slab, photos) return JSONResponse( status_code=201, content=result.model_dump(mode="json"), ) except HTTPException: await session.rollback() raise except Exception as exc: await session.rollback() logger.exception("Failed to create slab") raise HTTPException( status_code=500, detail={ "code": "internal", "message": f"Failed to create slab: {exc}", }, ) finally: await session.close()