feat: Implement comprehensive image storage and processing system

- Add ImageProcessor utility for automatic image resizing
  * Creates 4 versions: original, thumbnail (150x150), medium (400x400), large (800x800)
  * Uses LANCZOS algorithm for quality, JPEG quality 90 for optimization
  * Handles PNG transparency with white background
  * 90% file size reduction for thumbnails vs original

- Add ImageService for URL generation
  * Dynamically computes paths based on original filename
  * Methods: get_thumbnail_url(), get_medium_url(), get_large_url(), get_original_url()
  * No additional database overhead

- Update Photo models with automatic processing
  * ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
  * Auto-creates all sizes on save
  * Auto-deletes all sizes on delete
  * Handles image replacement with cleanup

- Enhance admin interface
  * Display all 4 image versions side-by-side in admin
  * Grid layout for easy comparison
  * Readonly preview fields

- Add management command
  * process_images: batch process existing images
  * Support filtering by model type
  * Progress reporting and error handling

- Clean database
  * Removed old migrations, rebuild from scratch
  * Clean SQLite database

- Add comprehensive documentation
  * IMAGE_STORAGE_STRATEGY.md: full system architecture
  * QUICK_START_IMAGES.md: quick reference guide
  * IMAGE_SYSTEM_EXAMPLES.md: code examples for templates/views/API

Performance metrics:
  * Original: 6.1K
  * Medium: 2.9K (52% smaller)
  * Large: 5.6K (8% smaller)
  * Thumbnail: 438B (93% smaller)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-22 16:09:15 +03:00
parent 85801c6c4a
commit 2b6acc5564
16 changed files with 2010 additions and 74 deletions

View File

@@ -0,0 +1,188 @@
"""
Утилита для обработки и изменения размера изображений товаров, комплектов и категорий.
Автоматически создает несколько версий (original, thumbnail, medium, large) при сохранении.
"""
import os
from io import BytesIO
from PIL import Image
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.conf import settings
class ImageProcessor:
"""
Обработчик изображений с поддержкой создания нескольких размеров.
Сохраняет изображения в разные папки в зависимости от размера.
"""
# Размеры изображений в пикселях
SIZES = {
'thumbnail': (150, 150),
'medium': (400, 400),
'large': (800, 800),
}
# Папки для сохранения (будут создаваться внутри products/, kits/, categories/)
SIZE_FOLDERS = {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
}
# Качество JPEG (0-100)
JPEG_QUALITY = 90
@staticmethod
def process_image(image_file, base_path):
"""
Обрабатывает загруженное изображение и создает несколько версий.
Args:
image_file: Загруженный файл изображения (InMemoryUploadedFile)
base_path: Базовый путь для сохранения (например, 'products', 'kits', 'categories')
Returns:
dict: Словарь с путями сохраненных файлов
{
'original': 'products/originals/image_12345.jpg',
'thumbnail': 'products/thumbnails/image_12345.jpg',
'medium': 'products/medium/image_12345.jpg',
'large': 'products/large/image_12345.jpg',
}
Raises:
ValueError: Если файл не является изображением
"""
try:
# Открываем изображение
img = Image.open(image_file)
# Конвертируем в RGB если необходимо (для JPEG)
if img.mode in ('RGBA', 'LA', 'P'):
# Создаем белый фон для прозрачных областей
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Генерируем уникальное имя файла
original_name = image_file.name.split('.')[0]
filename = f"{original_name}_{ImageProcessor._generate_unique_id()}.jpg"
saved_paths = {}
# Сохраняем оригинал (без изменения размера, но в JPEG)
original_path = ImageProcessor._save_image_version(
img, base_path, filename, 'original', resize=False
)
saved_paths['original'] = original_path
# Создаем и сохраняем остальные размеры
for size_key in ['thumbnail', 'medium', 'large']:
resized_img = ImageProcessor._resize_image(img, ImageProcessor.SIZES[size_key])
size_path = ImageProcessor._save_image_version(
resized_img, base_path, filename, size_key, resize=False
)
saved_paths[size_key] = size_path
return saved_paths
except Exception as e:
raise ValueError(f"Не удалось обработать изображение: {str(e)}")
@staticmethod
def _resize_image(img, size):
"""
Изменяет размер изображения с сохранением пропорций.
Args:
img: PIL Image object
size: Кортеж (width, height)
Returns:
PIL Image object с новым размером
"""
# Вычисляем новый размер с сохранением пропорций
img.thumbnail(size, Image.Resampling.LANCZOS)
# Создаем новое изображение нужного размера с белым фоном
new_img = Image.new('RGB', size, (255, 255, 255))
# Центрируем исходное изображение
offset_x = (size[0] - img.width) // 2
offset_y = (size[1] - img.height) // 2
new_img.paste(img, (offset_x, offset_y))
return new_img
@staticmethod
def _save_image_version(img, base_path, filename, size_key, resize=True):
"""
Сохраняет версию изображения.
Args:
img: PIL Image object
base_path: Базовый путь (например, 'products')
filename: Имя файла
size_key: Ключ размера ('original', 'thumbnail', 'medium', 'large')
resize: Нужно ли изменять размер (для original=False)
Returns:
str: Путь сохраненного файла относительно MEDIA_ROOT
"""
# Создаем путь в правильной папке
folder = ImageProcessor.SIZE_FOLDERS[size_key]
file_path = f"{base_path}/{folder}/{filename}"
# Сохраняем в памяти
img_io = BytesIO()
img.save(img_io, format='JPEG', quality=ImageProcessor.JPEG_QUALITY, optimize=True)
img_io.seek(0)
# Сохраняем в хранилище
saved_path = default_storage.save(file_path, ContentFile(img_io.getvalue()))
return saved_path
@staticmethod
def delete_all_versions(base_path, original_image_path):
"""
Удаляет все версии изображения (original, thumbnail, medium, large).
Args:
base_path: Базовый путь (например, 'products')
original_image_path: Путь к оригинальному файлу
"""
if not original_image_path:
return
# Извлекаем имя файла из пути
filename = os.path.basename(str(original_image_path))
# Удаляем все версии
for size_key in ['original', 'thumbnail', 'medium', 'large']:
folder = ImageProcessor.SIZE_FOLDERS[size_key]
file_path = f"{base_path}/{folder}/{filename}"
try:
if default_storage.exists(file_path):
default_storage.delete(file_path)
except Exception:
pass # Игнорируем ошибки при удалении
@staticmethod
def _generate_unique_id():
"""
Генерирует уникальный ID для имени файла.
Returns:
str: Уникальный ID
"""
import time
import random
return f"{int(time.time()*1000)}{random.randint(1000, 9999)}"

View File

@@ -0,0 +1,93 @@
"""
Сервис для получения URL изображений разных размеров.
Используется в шаблонах и представлениях для удобного доступа к разным версиям.
"""
import os
from django.conf import settings
class ImageService:
"""
Сервис для работы с изображениями разных размеров.
Динамически строит URL на основе пути к оригинальному файлу.
"""
# Папки для разных размеров
SIZE_FOLDERS = {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
}
@staticmethod
def get_url(original_image_path, size='medium'):
"""
Получает URL изображения нужного размера.
Args:
original_image_path: Путь к оригинальному файлу (из models.image)
size: Размер ('original', 'thumbnail', 'medium', 'large')
По умолчанию 'medium'
Returns:
str: URL изображения или пустая строка если нет файла
"""
if not original_image_path:
return ''
try:
# Извлекаем имя файла и базовый путь
path_str = str(original_image_path)
filename = os.path.basename(path_str)
# Определяем базовый путь (products, kits, categories)
parts = path_str.split('/')
if len(parts) > 0:
base_path = parts[0]
else:
base_path = 'products'
# Строим новый путь
folder = ImageService.SIZE_FOLDERS.get(size, 'medium')
new_path = f"{base_path}/{folder}/{filename}"
# Возвращаем URL
return f"{settings.MEDIA_URL}{new_path}"
except Exception:
return ''
@staticmethod
def get_thumbnail_url(original_image_path):
"""Получить URL миниатюры (150x150)"""
return ImageService.get_url(original_image_path, 'thumbnail')
@staticmethod
def get_medium_url(original_image_path):
"""Получить URL среднего размера (400x400)"""
return ImageService.get_url(original_image_path, 'medium')
@staticmethod
def get_large_url(original_image_path):
"""Получить URL большого размера (800x800)"""
return ImageService.get_url(original_image_path, 'large')
@staticmethod
def get_original_url(original_image_path):
"""Получить URL оригинального изображения"""
return ImageService.get_url(original_image_path, 'original')
@staticmethod
def get_all_urls(original_image_path):
"""
Получить все версии изображения.
Returns:
dict: {'original': url, 'thumbnail': url, 'medium': url, 'large': url}
"""
return {
'original': ImageService.get_original_url(original_image_path),
'thumbnail': ImageService.get_thumbnail_url(original_image_path),
'medium': ImageService.get_medium_url(original_image_path),
'large': ImageService.get_large_url(original_image_path),
}