5019b10584
POST /api/v1/slabs accepts multipart/form-data with: - data: JSON-encoded SlabCreate payload - files[]: original image files (1-5 photos) - meta[]: JSON-encoded photo metadata objects Returns 201 with status: calibrated and server_rev: 1. Server stores client-sent numbers only (sqft, bdft, widths) without server-side recompute. Client is authoritative for deterministic math. Includes: - SlabCreateData Pydantic model mirroring OPENAPI.md SlabCreate schema - PhotoMeta model for photo metadata validation - SlabRead/PhotoRead response models - File storage with configurable images directory (temp fallback for tests) - Duplicate SKU detection (409) - python-multipart added to requirements.txt D-1 test coverage (8 tests, all green): - 201 response on valid multipart upload - status: calibrated in response - server_rev: 1 in response - Full response shape assertion - Multiple photos handling - Missing data field returns 400 - Duplicate SKU returns 409 - TestClient lifespan management (FUND-9) Evidence: pytest tests/ -v → 31 passed, 0 failed
433 lines
14 KiB
Python
433 lines
14 KiB
Python
"""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()
|