Files
SlabUploader/backend/app/api/slabs.py
T
grokbot_ty 5019b10584 Gate B [09/10]: Draft store stub D-1 — POST /api/v1/slabs (#29)
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
2026-09-07 08:08:39 +00:00

433 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()