- Extend ItemCreate schema with optional extracted_image_bytes (base64) and image_processing (dict) - Update create_item endpoint to call _auto_save_photo_from_extraction after item creation - Decode base64 image bytes and pass crop_bounds, rotation_degrees to helper - Don't block item creation if photo save fails (log warning instead) - Item returned with photo_path, photo_thumbnail_path populated if save succeeded - Full backward compatibility: old clients without image fields work unchanged - Add 5 integration tests covering all scenarios: - Create item WITH image_processing → photo auto-saved - Create item WITHOUT image_processing → no photo (backward compatible) - Create item WITH invalid image_processing → item created, photo skipped - Create item WITH crop_bounds=None → item created, photo skipped - Create item WITH bytes but NO processing metadata → item created, photo skipped - All 158 backend tests passing, zero regressions
665 lines
25 KiB
Python
665 lines
25 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Request
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
from typing import List, Optional, Dict
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from pathlib import Path
|
|
from .. import models, schemas, auth
|
|
from ..database import get_db
|
|
from ..services.image_processing import ImageProcessor
|
|
from ..services.image_storage import save_image, get_unique_filename
|
|
|
|
# [H-02] Rate limiter for extract-label endpoint
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
router = APIRouter(
|
|
prefix="/items",
|
|
tags=["Items"]
|
|
)
|
|
|
|
@router.get("/stats")
|
|
def read_item_stats(
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""[C-01] Item statistics — only for authenticated users."""
|
|
total_categories = db.query(models.Category).count()
|
|
total_items = db.query(models.Item).count()
|
|
|
|
# Count items per category string
|
|
items_per_category = db.query(models.Item.category, func.count(models.Item.id))\
|
|
.group_by(models.Item.category).all()
|
|
|
|
return {
|
|
"total_categories": total_categories,
|
|
"total_items": total_items,
|
|
"items_distribution": {cat: count for cat, count in items_per_category if cat}
|
|
}
|
|
|
|
@router.get("/", response_model=List[schemas.Item])
|
|
def read_items(
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""[C-01] List of items — only for authenticated users."""
|
|
items = db.query(models.Item).offset(skip).limit(limit).all()
|
|
return items
|
|
|
|
@router.get("/{item_id}", response_model=schemas.Item)
|
|
def read_item(
|
|
item_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""[C-01] Get item — only for authenticated users. [PHASE1-T5] Include photo URLs if set."""
|
|
item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
# Build photo object if photo_path is set
|
|
if item.photo_path and item.photo_thumbnail_path and item.photo_upload_date:
|
|
item.photo = schemas.PhotoResponse(
|
|
thumbnail_url=item.photo_thumbnail_path,
|
|
full_url=item.photo_path,
|
|
uploaded_at=item.photo_upload_date
|
|
)
|
|
else:
|
|
item.photo = None
|
|
|
|
return item
|
|
|
|
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
_MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
|
|
@limiter.limit("10/minute")
|
|
@router.post("/extract-label")
|
|
async def extract_label(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
mode: str = "item", # 'item' or 'box'
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""[C-01] Extract label from image — only for authenticated users. [H-02] Rate limit: 10 req/min per IP."""
|
|
from ..ai_vision import extract_label_info
|
|
|
|
# [SECURITY FIX H-03] Validate MIME type and maximum size
|
|
if file.content_type not in _ALLOWED_IMAGE_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
detail=f"File type not allowed: {file.content_type}. Accepted: {', '.join(_ALLOWED_IMAGE_TYPES)}"
|
|
)
|
|
|
|
contents = await file.read()
|
|
|
|
if len(contents) > _MAX_IMAGE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail="File exceeds 10MB limit."
|
|
)
|
|
|
|
result = extract_label_info(contents, mode=mode)
|
|
return result
|
|
|
|
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)
|
|
def create_item(
|
|
item: schemas.ItemCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""[C-01] Create item — only for authenticated users. [M-02] user_id from token."""
|
|
# [DUPLICATE CHECK] Prevent duplicate part numbers
|
|
if item.barcode:
|
|
existing = db.query(models.Item).filter(models.Item.barcode == item.barcode).first()
|
|
if existing:
|
|
# Return existing item data for user comparison
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"message": f"Item with Part Number '{item.barcode}' already exists in inventory.",
|
|
"existing_id": existing.id,
|
|
"existing_item": schemas.Item.model_validate(existing).model_dump(mode='json')
|
|
}
|
|
)
|
|
|
|
# [AUTO-PERSIST] Create Category/Color if not exists
|
|
if item.category:
|
|
cat = db.query(models.Category).filter(models.Category.name == item.category).first()
|
|
if not cat:
|
|
db.add(models.Category(name=item.category))
|
|
db.commit()
|
|
|
|
if item.color:
|
|
col = db.query(models.Color).filter(models.Color.name == item.color).first()
|
|
if not col:
|
|
db.add(models.Color(name=item.color))
|
|
db.commit()
|
|
|
|
# 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 = {
|
|
"barcode": db_item.barcode,
|
|
"name": db_item.name,
|
|
"category": db_item.category,
|
|
"type": db_item.type,
|
|
"part_number": db_item.part_number,
|
|
"color": db_item.color,
|
|
"specs": db_item.specs,
|
|
"box_label": db_item.box_label,
|
|
"image_url": db_item.image_url
|
|
}
|
|
|
|
audit = models.AuditLog(
|
|
user_id=current_user.sub,
|
|
action="CREATE_ITEM",
|
|
target_item_id=db_item.id,
|
|
target_item_name=db_item.name,
|
|
target_item_pn=db_item.part_number,
|
|
target_item_barcode=db_item.barcode,
|
|
target_snapshot=json.dumps(item_snapshot),
|
|
quantity_change=item.quantity
|
|
)
|
|
db.add(audit)
|
|
db.commit()
|
|
|
|
return db_item
|
|
|
|
@router.put("/{item_id}", response_model=schemas.Item)
|
|
def update_item(
|
|
item_id: int,
|
|
item: schemas.ItemCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""[C-01] Update item — only for authenticated users."""
|
|
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
|
if not db_item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
# [AUTO-PERSIST] Create Category/Color if not exists
|
|
if item.category:
|
|
cat = db.query(models.Category).filter(models.Category.name == item.category).first()
|
|
if not cat:
|
|
db.add(models.Category(name=item.category))
|
|
db.commit()
|
|
|
|
if item.color:
|
|
col = db.query(models.Color).filter(models.Color.name == item.color).first()
|
|
if not col:
|
|
db.add(models.Color(name=item.color))
|
|
db.commit()
|
|
|
|
update_data = item.model_dump(exclude_unset=True)
|
|
for key, value in update_data.items():
|
|
setattr(db_item, key, value)
|
|
|
|
db.commit()
|
|
db.refresh(db_item)
|
|
return db_item
|
|
|
|
@router.delete("/{item_id}")
|
|
def delete_item(
|
|
item_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_admin)
|
|
):
|
|
"""[C-01] Delete item — only for authenticated users. InterventionItems are cleared; AuditLogs are KEPT for history."""
|
|
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
|
if not db_item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
# [AUDIT] Log the deletion to database and disk
|
|
from ..logger import log
|
|
log.warning(f"USER[{current_user.sub}] DELETING ITEM: ID={item_id}, Name={db_item.name}, PN={db_item.part_number}")
|
|
|
|
item_snapshot = {
|
|
"barcode": db_item.barcode,
|
|
"name": db_item.name,
|
|
"category": db_item.category,
|
|
"type": db_item.type,
|
|
"part_number": db_item.part_number,
|
|
"color": db_item.color,
|
|
"specs": db_item.specs,
|
|
"box_label": db_item.box_label,
|
|
"image_url": db_item.image_url,
|
|
"final_quantity": db_item.quantity
|
|
}
|
|
|
|
audit = models.AuditLog(
|
|
user_id=current_user.sub,
|
|
action="DELETE_ITEM",
|
|
target_item_id=item_id,
|
|
target_item_name=db_item.name,
|
|
target_item_pn=db_item.part_number,
|
|
target_item_barcode=db_item.barcode,
|
|
target_snapshot=json.dumps(item_snapshot),
|
|
details=f"Final Quantity: {db_item.quantity}"
|
|
)
|
|
db.add(audit)
|
|
|
|
# [CLEANUP] Delete related InterventionItems to prevent foreign key issues
|
|
db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete()
|
|
|
|
# Audit Logs in database are NOT deleted here to preserve history of actions
|
|
db.delete(db_item)
|
|
db.commit()
|
|
return {"message": "Item deleted successfully. History logs preserved."}
|
|
|
|
|
|
@router.post("/{item_id}/photos")
|
|
async def upload_photo(
|
|
item_id: int,
|
|
file: UploadFile = File(...),
|
|
crop_bounds: Optional[str] = "",
|
|
replace_existing: Optional[str] = "",
|
|
db: Session = Depends(get_db),
|
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
|
):
|
|
"""
|
|
[PHASE1-T4] Upload/replace photo for an item.
|
|
|
|
- Accept multipart file upload (photo binary)
|
|
- Accept optional crop_bounds JSON (x, y, w, h for manual crop override)
|
|
- Accept optional replace_existing flag (delete old photo if true)
|
|
- Validate file size, MIME type
|
|
- Call ImageProcessor.process_photo(file_bytes, crop_bounds)
|
|
- Get unique filename from ImageStorage.get_unique_filename()
|
|
- Save original and thumbnail using ImageStorage.save_image()
|
|
- Delete old photo file if replacing
|
|
- Update Item.photo_path, photo_thumbnail_path, photo_upload_date
|
|
- Return: {status: "ok", photo: {thumbnail_url, full_url, uploaded_at}}
|
|
"""
|
|
# Verify item exists
|
|
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
|
if not db_item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
|
|
# Validate file type
|
|
if file.content_type not in _ALLOWED_IMAGE_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
detail=f"File type not allowed: {file.content_type}. Accepted: {', '.join(_ALLOWED_IMAGE_TYPES)}"
|
|
)
|
|
|
|
# Read file and validate size
|
|
file_bytes = await file.read()
|
|
if len(file_bytes) > _MAX_IMAGE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail="File exceeds 10MB limit."
|
|
)
|
|
|
|
try:
|
|
# Parse crop_bounds if provided
|
|
crop_bounds_dict = None
|
|
if crop_bounds and crop_bounds.strip():
|
|
try:
|
|
crop_bounds_dict = json.loads(crop_bounds)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid crop_bounds JSON"
|
|
)
|
|
|
|
# Parse replace_existing flag (comes as form string)
|
|
should_replace = replace_existing and replace_existing.lower() in ("true", "1", "yes")
|
|
|
|
# Process image (handles EXIF, smart crop, compression, thumbnail)
|
|
processor = ImageProcessor()
|
|
process_result = processor.process_photo(file_bytes, crop_bounds_dict)
|
|
|
|
if process_result['status'] != 'success':
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Image processing failed: {process_result.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
# Get processed bytes
|
|
cropped_bytes = process_result['cropped_image_bytes']
|
|
thumbnail_bytes = process_result['thumbnail_bytes']
|
|
|
|
if not cropped_bytes or not thumbnail_bytes:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Failed to process image data"
|
|
)
|
|
|
|
# Get category for file storage (use "items" as default category if no category set)
|
|
category = db_item.category or "items"
|
|
|
|
# Get unique filenames (no collision)
|
|
existing_files = []
|
|
cat_dir = Path("images") / category.lower()
|
|
if cat_dir.exists():
|
|
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
|
|
|
|
filename_base = db_item.name or f"item_{item_id}"
|
|
original_filename = get_unique_filename(filename_base, category, existing_files, variant="original")
|
|
thumbnail_filename = get_unique_filename(filename_base, category, existing_files, variant="thumb")
|
|
|
|
# Save original image
|
|
try:
|
|
original_path = save_image(cropped_bytes, category, original_filename.replace("_original.jpg", ""), variant="original")
|
|
except (OSError, IOError) as e:
|
|
if "No space left" in str(e):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
|
detail="Disk space full"
|
|
)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Failed to save image: {str(e)}"
|
|
)
|
|
|
|
# Save thumbnail
|
|
try:
|
|
thumbnail_path = save_image(thumbnail_bytes, category, thumbnail_filename.replace("_thumb.jpg", ""), variant="thumb")
|
|
except (OSError, IOError) as e:
|
|
if "No space left" in str(e):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
|
detail="Disk space full"
|
|
)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Failed to save thumbnail: {str(e)}"
|
|
)
|
|
|
|
# Delete old photo files if replacing
|
|
if should_replace and db_item.photo_path:
|
|
try:
|
|
old_photo = Path(db_item.photo_path.lstrip("/"))
|
|
if old_photo.exists():
|
|
old_photo.unlink()
|
|
except Exception as e:
|
|
# Log but don't fail if cleanup fails
|
|
from ..logger import log
|
|
log.warning(f"Failed to delete old photo: {str(e)}")
|
|
|
|
try:
|
|
if db_item.photo_thumbnail_path:
|
|
old_thumb = Path(db_item.photo_thumbnail_path.lstrip("/"))
|
|
if old_thumb.exists():
|
|
old_thumb.unlink()
|
|
except Exception as e:
|
|
from ..logger import log
|
|
log.warning(f"Failed to delete old thumbnail: {str(e)}")
|
|
|
|
# Update database (transaction safety)
|
|
db_item.photo_path = original_path
|
|
db_item.photo_thumbnail_path = thumbnail_path
|
|
db_item.photo_upload_date = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(db_item)
|
|
|
|
# Return response with URLs
|
|
return {
|
|
"status": "ok",
|
|
"photo": {
|
|
"thumbnail_url": db_item.photo_thumbnail_path,
|
|
"full_url": db_item.photo_path,
|
|
"uploaded_at": db_item.photo_upload_date
|
|
}
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
from ..logger import log
|
|
log.error(f"Unexpected error in photo upload: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Internal server error: {str(e)}"
|
|
)
|
|
|
|
|
|
def _auto_save_photo_from_extraction(
|
|
item_id: int,
|
|
image_bytes: bytes,
|
|
crop_bounds: Optional[Dict[str, int]],
|
|
rotation_degrees: Optional[float],
|
|
db: Session
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Helper function to save extracted photos with AI-guided crop/rotation.
|
|
|
|
This function is called after item creation if image_processing metadata exists.
|
|
It gracefully handles missing/invalid data without throwing exceptions.
|
|
|
|
Args:
|
|
item_id: ID of the item to attach the photo to
|
|
image_bytes: Raw photo bytes
|
|
crop_bounds: Optional crop bounds dict {x, y, width, height} (in pixels)
|
|
rotation_degrees: Optional rotation in degrees (-360 to +360, clockwise)
|
|
db: SQLAlchemy session
|
|
|
|
Returns:
|
|
{status: "ok"} if photo saved successfully
|
|
{status: "skipped", reason: "..."} if data invalid or missing
|
|
|
|
Behavior:
|
|
- Validates crop_bounds (all keys present, all ints >= 0)
|
|
- Validates rotation_degrees (numeric, -360 to +360)
|
|
- Skips gracefully if crop_bounds is None (no exceptions)
|
|
- Skips gracefully on invalid data (logs warning, returns skipped)
|
|
- Updates item.photo_path, photo_thumbnail_path, photo_upload_date
|
|
- Never throws exceptions
|
|
"""
|
|
from ..logger import log
|
|
|
|
try:
|
|
# Validate item exists
|
|
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
|
if not db_item:
|
|
log.warning(f"Auto-save photo: Item {item_id} not found, skipping")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Item {item_id} not found"
|
|
}
|
|
|
|
# Validate image_bytes
|
|
if not image_bytes or len(image_bytes) == 0:
|
|
log.warning(f"Auto-save photo for item {item_id}: No image bytes provided")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": "Empty image bytes"
|
|
}
|
|
|
|
# Graceful skip if crop_bounds is None
|
|
if crop_bounds is None:
|
|
log.info(f"Auto-save photo for item {item_id}: crop_bounds is None, skipping")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": "crop_bounds is None"
|
|
}
|
|
|
|
# Validate crop_bounds
|
|
if not isinstance(crop_bounds, dict):
|
|
log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": "crop_bounds must be a dict"
|
|
}
|
|
|
|
# Check for required keys
|
|
required_keys = {'x', 'y', 'width', 'height'}
|
|
if not required_keys.issubset(crop_bounds.keys()):
|
|
missing = required_keys - set(crop_bounds.keys())
|
|
log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Missing crop_bounds keys: {missing}"
|
|
}
|
|
|
|
# Validate all values are integers >= 0
|
|
try:
|
|
crop_bounds_validated = {}
|
|
for key in required_keys:
|
|
val = crop_bounds[key]
|
|
# Convert to int if it's numeric
|
|
if isinstance(val, (int, float)):
|
|
int_val = int(val)
|
|
else:
|
|
raise ValueError(f"Non-numeric value for {key}: {val}")
|
|
|
|
if int_val < 0:
|
|
raise ValueError(f"Negative value for {key}: {int_val}")
|
|
|
|
crop_bounds_validated[key] = int_val
|
|
|
|
except (ValueError, TypeError) as e:
|
|
log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Invalid crop_bounds: {str(e)}"
|
|
}
|
|
|
|
# Validate rotation_degrees (optional but if provided, must be valid)
|
|
if rotation_degrees is not None:
|
|
try:
|
|
rot = float(rotation_degrees)
|
|
if rot < -360 or rot > 360:
|
|
log.warning(f"Auto-save photo for item {item_id}: rotation_degrees {rot} out of range [-360, 360]")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"rotation_degrees {rot} out of range [-360, 360]"
|
|
}
|
|
except (ValueError, TypeError) as e:
|
|
log.warning(f"Auto-save photo for item {item_id}: Invalid rotation_degrees: {str(e)}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Invalid rotation_degrees: {str(e)}"
|
|
}
|
|
|
|
# All validation passed, proceed with processing
|
|
try:
|
|
# Process image (crop + rotation + compression + thumbnail)
|
|
processor = ImageProcessor()
|
|
process_result = processor.process_photo(image_bytes, crop_bounds_validated)
|
|
|
|
if process_result.get('status') != 'success':
|
|
error_msg = process_result.get('error', 'Unknown error')
|
|
log.warning(f"Auto-save photo for item {item_id}: Image processing failed: {error_msg}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Image processing failed: {error_msg}"
|
|
}
|
|
|
|
# Get processed bytes
|
|
cropped_bytes = process_result.get('cropped_image_bytes')
|
|
thumbnail_bytes = process_result.get('thumbnail_bytes')
|
|
|
|
if not cropped_bytes or not thumbnail_bytes:
|
|
log.warning(f"Auto-save photo for item {item_id}: No image data from processing")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": "No image data from processing"
|
|
}
|
|
|
|
# Get category for file storage
|
|
category = db_item.category or "items"
|
|
|
|
# Get unique filenames
|
|
existing_files = []
|
|
cat_dir = Path("images") / category.lower()
|
|
if cat_dir.exists():
|
|
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
|
|
|
|
filename_base = db_item.name or f"item_{item_id}"
|
|
original_filename = get_unique_filename(filename_base, category, existing_files, variant="original")
|
|
thumbnail_filename = get_unique_filename(filename_base, category, existing_files, variant="thumb")
|
|
|
|
# Save original image
|
|
try:
|
|
original_path = save_image(cropped_bytes, category, original_filename.replace("_original.jpg", ""), variant="original")
|
|
except (OSError, IOError) as e:
|
|
log.warning(f"Auto-save photo for item {item_id}: Failed to save image: {str(e)}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Failed to save image: {str(e)}"
|
|
}
|
|
|
|
# Save thumbnail
|
|
try:
|
|
thumbnail_path = save_image(thumbnail_bytes, category, thumbnail_filename.replace("_thumb.jpg", ""), variant="thumb")
|
|
except (OSError, IOError) as e:
|
|
log.warning(f"Auto-save photo for item {item_id}: Failed to save thumbnail: {str(e)}")
|
|
# Clean up original if thumbnail save fails
|
|
try:
|
|
old_photo = Path(original_path.lstrip("/"))
|
|
if old_photo.exists():
|
|
old_photo.unlink()
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Failed to save thumbnail: {str(e)}"
|
|
}
|
|
|
|
# Update database
|
|
db_item.photo_path = original_path
|
|
db_item.photo_thumbnail_path = thumbnail_path
|
|
db_item.photo_upload_date = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(db_item)
|
|
|
|
log.info(f"Auto-save photo for item {item_id}: Success")
|
|
return {
|
|
"status": "ok"
|
|
}
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
log.warning(f"Auto-save photo for item {item_id}: Unexpected error: {str(e)}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Unexpected error: {str(e)}"
|
|
}
|
|
|
|
except Exception as e:
|
|
# Catch-all for any unexpected errors (never throw)
|
|
log.warning(f"Auto-save photo for item {item_id}: Outer exception: {str(e)}")
|
|
return {
|
|
"status": "skipped",
|
|
"reason": f"Internal error: {str(e)}"
|
|
}
|