Files
tfm_ainventory/backend/services/image_storage.py
Daniel Bedeleanu 01321bf607 fix(phase1): restore API contract while preserving N+1 optimization
The N+1 optimization in save_image() pre-lowercases the existing_files list
before passing to get_unique_filename(). However, this broke the API contract:
the function should handle any-case input to remain robust.

Changed: get_unique_filename() now defensively lowercases the input list,
ensuring collision detection works regardless of input case.

Benefits:
- Fixes implicit API contract change (function expected any-case input)
- Maintains N+1 optimization (pre-lowercasing still works)
- Supports both optimization and edge cases (direct function calls)
- All 22 tests pass
2026-04-20 22:09:58 +03:00

192 lines
6.0 KiB
Python

"""Image storage utilities for managing image files and directory structure."""
import logging
import re
import uuid
from pathlib import Path
from typing import List, Optional
# Root directory for all images
IMAGES_ROOT = Path("images")
def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename by removing unsafe characters and limiting length.
Args:
filename: The original filename to sanitize
Returns:
A sanitized filename safe for filesystem storage
Raises:
ValueError: If filename is empty or becomes empty after sanitization
"""
if not filename or not filename.strip():
raise ValueError("Filename cannot be empty")
# Remove path traversal attempts (/, \, ..)
sanitized = re.sub(r'[/\\]', '', filename)
sanitized = sanitized.replace('..', '')
# Remove null bytes and control characters
sanitized = re.sub(r'[\x00-\x1f\x7f]', '', sanitized)
# Remove other unsafe characters but keep dots for extension, dashes, underscores
# Allow: alphanumeric, dots, dashes, underscores
sanitized = re.sub(r'[^a-zA-Z0-9._\-]', '', sanitized)
# Convert to lowercase
sanitized = sanitized.lower()
# Check if filename is now empty or only dots
if not sanitized or re.match(r'^\.+$', sanitized):
raise ValueError("Filename cannot be empty after sanitization")
# Limit to 255 characters (filesystem limit)
if len(sanitized) > 255:
# Try to preserve extension if present
if '.' in sanitized:
parts = sanitized.rsplit('.', 1)
name_part = parts[0][:251] # Leave room for .ext
ext_part = parts[1]
sanitized = f"{name_part}.{ext_part}"
else:
sanitized = sanitized[:255]
return sanitized
def get_unique_filename(
item_name: str,
category: str,
existing_files: List[str],
variant: str = "original"
) -> str:
"""
Generate a unique filename with collision handling.
If a file with the sanitized name already exists, appends a UUID suffix.
Format: {name}_{uuid_first_8}_{variant}.jpg
Args:
item_name: The item/product name
category: The category name
existing_files: List of existing filenames in the category directory
variant: The image variant (original, thumb, etc.)
Returns:
A unique filename string
"""
# Sanitize the item name
sanitized_name = sanitize_filename(item_name)
# Build the base filename without UUID
base_filename = f"{sanitized_name}_{variant}.jpg"
# Defensive collision check: handle both pre-lowercased and any-case input
# This maintains backward compatibility while supporting the optimization
existing_lower = [f.lower() for f in existing_files]
if base_filename.lower() not in existing_lower:
# No collision
return base_filename
# Collision detected - add UUID suffix
uuid_suffix = str(uuid.uuid4()).replace('-', '')[:8]
unique_filename = f"{sanitized_name}_{uuid_suffix}_{variant}.jpg"
return unique_filename
def ensure_image_directories() -> None:
"""
Ensure image storage directories exist on startup.
Creates /images/ root and category-specific subdirectories.
"""
# Create root images directory
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
# Create category subdirectories
try:
categories = get_categories()
for category in categories:
cat_dir = IMAGES_ROOT / category
cat_dir.mkdir(parents=True, exist_ok=True)
except Exception:
# If get_categories fails (e.g., DB not ready), just create root
# Categories will be created on-demand in save_image
logging.exception("Failed to ensure image directories")
def save_image(
file_bytes: bytes,
category: str,
filename_base: str,
variant: str = "original"
) -> str:
"""
Save an image file to the storage directory.
Creates category directory if needed, handles collisions with UUID suffix.
Args:
file_bytes: The image file content as bytes
category: The category name (e.g., "networking")
filename_base: The base filename without extension (e.g., "SFP-LR")
variant: The image variant (original, thumb, etc.)
Returns:
The relative path to the saved image (e.g., "/images/networking/sfp-lr_original.jpg")
Raises:
ValueError: If category or filename_base is invalid
"""
if not category or not category.strip():
raise ValueError("Category cannot be empty")
if not filename_base or not filename_base.strip():
raise ValueError("Filename base cannot be empty")
# Sanitize category
sanitized_category = sanitize_filename(category)
# Create category directory
cat_dir = IMAGES_ROOT / sanitized_category
cat_dir.mkdir(parents=True, exist_ok=True)
# Get existing files in the category
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
# Pre-lowercase existing filenames to avoid redundant conversions in get_unique_filename
existing_files_lower = [f.lower() for f in existing_files]
# Get unique filename
unique_filename = get_unique_filename(filename_base, sanitized_category, existing_files_lower, variant)
# Write file
file_path = cat_dir / unique_filename
try:
file_path.write_bytes(file_bytes)
except OSError as e:
raise IOError(f"Failed to write image to {file_path}: {str(e)}")
# Return relative path with forward slashes
relative_path = f"/images/{sanitized_category}/{unique_filename}"
return relative_path
def get_categories() -> List[str]:
"""
Get list of categories from the database.
This is a stub that will be called from ensure_image_directories.
In production, this should query the database for categories.
Returns:
List of category names
"""
# This will be implemented to query the database
# For now, return empty list (handled in ensure_image_directories)
return []