- Add rotation_degrees parameter to ImageProcessor.process_photo() - Pass rotation through _auto_save_photo_from_extraction() to processor - Allow no-crop fallback when crop_bounds is None - Add buildPhotoUrl() helper to resolve backend URLs correctly - Update frontend components to use backend URL for image sources - Replace Use/Skip Photo buttons with checkbox in AI extraction UI - Add images/ to .gitignore to prevent accidental commits Addresses: rotation never applied, image 404s (relative to Next.js not backend), preview blank in edit form
659 lines
25 KiB
Python
659 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"
|
|
}
|
|
|
|
# Validate crop_bounds (if provided)
|
|
crop_bounds_validated = None
|
|
if crop_bounds is not None:
|
|
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, rotation_degrees=rotation_degrees or 0)
|
|
|
|
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)}"
|
|
}
|