feat: add _auto_save_photo_from_extraction helper with graceful fallbacks
This commit is contained in:
@@ -423,3 +423,216 @@ async def upload_photo(
|
||||
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)}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user