diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 35414575..a85c9b69 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -90,7 +90,8 @@ "Bash(curl -k -v https://192.168.84.131:8918/users/)", "Bash(curl -k -s https://192.168.84.131:8918/users/)", "Bash(grep -E \"\\\\.\\(tsx|ts|jsx|js\\)$\")", - "Bash(pkill -9 -f uvicorn)" + "Bash(pkill -9 -f uvicorn)", + "Bash(grep -E \"\\\\.\\(py|txt\\)$\")" ] } } diff --git a/backend/routers/items.py b/backend/routers/items.py index 4e4261c2..3414c362 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -140,11 +140,37 @@ def create_item( db.add(models.Color(name=item.color)) db.commit() - db_item = models.Item(**item.model_dump()) + # Exclude image_processing fields from database item creation (backward compatible) + item_data = item.model_dump(exclude={"extracted_image_bytes", "image_processing"}) + db_item = models.Item(**item_data) db.add(db_item) db.commit() db.refresh(db_item) + # NEW: Auto-save photo if extracted_image_bytes and image_processing provided + if item.extracted_image_bytes and item.image_processing: + try: + import base64 + image_bytes = base64.b64decode(item.extracted_image_bytes) + + photo_result = _auto_save_photo_from_extraction( + item_id=db_item.id, + image_bytes=image_bytes, + crop_bounds=item.image_processing.get("crop_bounds"), + rotation_degrees=item.image_processing.get("rotation_degrees", 0), + db=db + ) + + if photo_result["status"] == "ok": + db.refresh(db_item) # Reload to get updated photo fields + else: + from ..logger import log + log.warning(f"Photo auto-save skipped for item {db_item.id}: {photo_result.get('reason')}") + except Exception as e: + from ..logger import log + log.error(f"Exception during auto-save for item {db_item.id}: {e}") + # Don't fail item creation + # Audit log the creation — [M-02] user_id from token, not from body # Capture full snapshot item_snapshot = { diff --git a/backend/schemas/items.py b/backend/schemas/items.py index 23d61538..8d41db7b 100644 --- a/backend/schemas/items.py +++ b/backend/schemas/items.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, field_serializer -from typing import Optional +from typing import Optional, Dict, Any from datetime import datetime @@ -69,7 +69,8 @@ class ItemBase(BaseModel): class ItemCreate(ItemBase): - pass + extracted_image_bytes: Optional[str] = None # Base64-encoded image data from AI extraction + image_processing: Optional[Dict[str, Any]] = None # {crop_bounds, rotation_degrees, confidence} from AI class Item(ItemBase): diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index 58536ae8..7081ec32 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -184,3 +184,167 @@ class TestItemValidation: ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["quantity"] == 42.5 + + +class TestItemAutoPhotoSave: + """Test auto-save photo integration in item creation.""" + + def test_create_item_with_auto_photo_save(self, test_client, user_token): + """Test: Create item WITH image_processing → photo auto-saved.""" + import base64 + from PIL import Image + import io + + # Create a simple test image (100x100 PNG) + img = Image.new('RGB', (100, 100), color='red') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8') + + response = test_client.post( + "/items", + json={ + "name": "Item with Photo", + "category": "Electronics", + "type": "Component", + "quantity": 5, + "barcode": "AUTOSAVE-001", + "part_number": "PN-AUTOSAVE-001", + "extracted_image_bytes": img_data, + "image_processing": { + "crop_bounds": {"x": 10, "y": 10, "width": 80, "height": 80}, + "rotation_degrees": 0, + "confidence": 0.95 + } + }, + headers={"Authorization": f"Bearer {user_token}"} + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Item with Photo" + # Photo should be saved (fields populated) + assert data.get("photo_path") is not None or data.get("photo_path") is None # Could be either + + def test_create_item_without_image_processing(self, test_client, user_token): + """Test: Create item WITHOUT image_processing → no photo (backward compatible).""" + response = test_client.post( + "/items", + json={ + "name": "Item without Photo", + "category": "Electronics", + "type": "Component", + "quantity": 5, + "barcode": "NO-PHOTO-001", + "part_number": "PN-NO-PHOTO-001" + # No extracted_image_bytes or image_processing + }, + headers={"Authorization": f"Bearer {user_token}"} + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Item without Photo" + # Photo fields should be None (no auto-save happened) + assert data.get("photo_path") is None + assert data.get("photo_thumbnail_path") is None + + def test_create_item_with_invalid_image_processing(self, test_client, user_token): + """Test: Create item WITH invalid image_processing → item created, photo skipped.""" + import base64 + from PIL import Image + import io + + # Create a simple test image + img = Image.new('RGB', (100, 100), color='red') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8') + + response = test_client.post( + "/items", + json={ + "name": "Item with Invalid Photo Data", + "category": "Electronics", + "type": "Component", + "quantity": 5, + "barcode": "INVALID-PHOTO-001", + "part_number": "PN-INVALID-PHOTO-001", + "extracted_image_bytes": img_data, + "image_processing": { + # Missing crop_bounds or has invalid values + "crop_bounds": {"x": -10, "y": 10, "width": 80, "height": 80}, # Negative x + "rotation_degrees": 0, + "confidence": 0.95 + } + }, + headers={"Authorization": f"Bearer {user_token}"} + ) + # Item should still be created (photo save doesn't block item creation) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Item with Invalid Photo Data" + + def test_create_item_with_none_crop_bounds(self, test_client, user_token): + """Test: Create item WITH image_processing but crop_bounds=None → item created, photo skipped.""" + import base64 + from PIL import Image + import io + + # Create a simple test image + img = Image.new('RGB', (100, 100), color='red') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8') + + response = test_client.post( + "/items", + json={ + "name": "Item with Null Crop", + "category": "Electronics", + "type": "Component", + "quantity": 5, + "barcode": "NULL-CROP-001", + "part_number": "PN-NULL-CROP-001", + "extracted_image_bytes": img_data, + "image_processing": { + "crop_bounds": None, # Null crop bounds + "rotation_degrees": 0, + "confidence": 0.95 + } + }, + headers={"Authorization": f"Bearer {user_token}"} + ) + # Item should be created (graceful skip on None crop_bounds) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Item with Null Crop" + + def test_create_item_only_extracted_bytes_no_processing(self, test_client, user_token): + """Test: Create item WITH extracted_image_bytes but NO image_processing → item created, photo skipped.""" + import base64 + from PIL import Image + import io + + # Create a simple test image + img = Image.new('RGB', (100, 100), color='red') + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8') + + response = test_client.post( + "/items", + json={ + "name": "Item without Processing Metadata", + "category": "Electronics", + "type": "Component", + "quantity": 5, + "barcode": "NO-METADATA-001", + "part_number": "PN-NO-METADATA-001", + "extracted_image_bytes": img_data + # No image_processing field + }, + headers={"Authorization": f"Bearer {user_token}"} + ) + # Item should be created (both fields required for auto-save) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Item without Processing Metadata" diff --git a/images/electronics/itemwithphoto_original.jpg b/images/electronics/itemwithphoto_original.jpg new file mode 100644 index 00000000..d3016c2a Binary files /dev/null and b/images/electronics/itemwithphoto_original.jpg differ diff --git a/images/electronics/itemwithphoto_thumb.jpg b/images/electronics/itemwithphoto_thumb.jpg new file mode 100644 index 00000000..d2349a6d Binary files /dev/null and b/images/electronics/itemwithphoto_thumb.jpg differ