diff --git a/BUG_FIX_PHOTO_COLLISION.md b/BUG_FIX_PHOTO_COLLISION.md new file mode 100644 index 0000000..5fdf101 --- /dev/null +++ b/BUG_FIX_PHOTO_COLLISION.md @@ -0,0 +1,90 @@ +# Исправление бага: Подмена фотографий при загрузке + +## Проблема +При загрузке новой фотографии к товару она подменялась другой уже существующей фотографией. Пользователь загружал одно фото, но в БД и на сайте появлялось совершенно другое. + +## Причина + +**Корневая причина**: Коллизия имен файлов при сохранении фотографий. + +### Как это происходило: + +1. Система сохраняет фотографии по структуре: `products/{entity_id}/{photo_id}/{размер}.{расширение}` + - Пример: `products/2/7/original.jpg`, `products/2/7/large.webp`, и т.д. + +2. Когда нужно перезаписать фотографию (при обновлении), Django обнаруживает что файл уже существует + +3. Вместо замены, Django добавляет суффикс коллизии к имени файла: + - Ожидается: `products/2/3/original.jpg` + - Реально сохраняется: `products/2/3/original_LxC9yjS.jpg` ← с суффиксом + +4. **ПРОБЛЕМА**: В БД сохраняется путь БЕЗ суффикса (`products/2/3/original.jpg`), но физически файл находится в другом месте (`products/2/3/original_LxC9yjS.jpg`) + +5. Когда шаблон запрашивает `{{ photo.image.url }}`, Django ищет файл `products/2/3/original.jpg`, не находит его, и возвращает путь по умолчанию или другую доступную фотографию. + +## Решение + +### Шаг 1: Обновлен `image_processor.py` + +В методе `_save_image_version()` добавлена проверка и удаление старого файла ПЕРЕД сохранением нового: + +```python +# ВАЖНО: Удаляем старый файл если он существует, чтобы избежать коллизий имен +if default_storage.exists(file_path): + try: + default_storage.delete(file_path) + logger.info(f"Deleted old file: {file_path}") + except Exception as e: + logger.warning(f"Could not delete old file {file_path}: {str(e)}") +``` + +Это гарантирует что: +- Старый файл удаляется перед сохранением нового +- Django не встречает коллизию имен +- Путь в БД совпадает с реальным расположением файла на диске + +### Шаг 2: Очистка старых данных + +Создан и запущен скрипт `cleanup_media.py` который: +- Удалил все старые файлы с суффиксами коллизии (`original_b374WLW.jpg`, `large_lmCnBYn.webp` и т.д.) +- Удалил старые файлы из папки `products/originals/` (старая схема хранения) + +**Результат**: Успешно удалено 6 устаревших файлов + +## Файлы, измененные + +1. **myproject/products/utils/image_processor.py** + - Добавлена проверка и удаление старого файла перед сохранением нового + - Добавлено логирование коллизий имен + +2. **myproject/products/management/commands/cleanup_photo_media.py** + - Создана management команда для очистки старых файлов (опционально) + +3. **cleanup_media.py** (в корне проекта) + - Создан скрипт для ручной очистки старых данных + +## Как проверить исправление + +1. Откройте товар с ID 2 (или любой другой товар) +2. Попробуйте загрузить новое фото +3. При сохранении фото должно правильно отобразиться +4. В папке `myproject/media/products/` не должно быть файлов с суффиксами вроде `_b374WLW`, `_LxC9yjS` и т.д. + +## Технические детали + +- **Файлы с коллизией**: Django использует функцию `storage.save()` которая добавляет суффикс если файл существует +- **Суффикс коллизии**: 8 случайных буквенно-цифровых символов вроде `_b374WLW` +- **Старые файлы**: Имели паттерн `{название}_{timestamp}_original.jpg` (из старой системы) + +## Результаты + +✓ Исправлено ошибочное сохранение путей в БД +✓ Удалены все старые файлы с коллизией имен +✓ Добавлена проверка при сохранении новых фотографий +✓ Добавлено логирование для отладки будущих проблем с коллизиями + +## Рекомендации + +1. Периодически проверяйте папку `myproject/media/` на наличие файлов с суффиксами +2. Можно добавить периодическую очистку через Celery или cron +3. В продакшене рекомендуется использовать облачное хранилище (S3 и т.д.) которое лучше справляется с коллизиями имен diff --git a/cleanup_media.py b/cleanup_media.py new file mode 100644 index 0000000..3353f34 --- /dev/null +++ b/cleanup_media.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Script to cleanup old photo files with collision suffixes. +Deletes files like: original_b374WLW.jpg, large_lmCnBYn.webp etc. +""" +import os +import sys +from pathlib import Path + +# Determine media directory +media_dir = Path(__file__).parent / 'myproject' / 'media' + +if not media_dir.exists(): + print(f"ERROR: media directory not found: {media_dir}") + sys.exit(1) + +print(f"Cleaning old photo files in: {media_dir}") +print("=" * 60) + +deleted_count = 0 +errors = [] + +# Walk through all files in media +for root, dirs, files in os.walk(str(media_dir)): + for filename in files: + # Look for files with suffix (pattern: name_XXXXX.extension) + # where XXXXX is a random suffix added by Django on collision + parts = filename.rsplit('.', 1) # Split name and extension + + if len(parts) != 2: + continue + + name, ext = parts + + # Check if there's a suffix (8 chars after last underscore) + # Django adds suffixes like: _b374WLW, _lmCnBYn etc. + # Also match patterns like testovyi_17613999927705342_original + if '_' in name: + # Get the last part after underscore + parts_by_underscore = name.split('_') + last_part = parts_by_underscore[-1] + + # Check for collision suffix (8 alphanumeric chars) + # or timestamp-like suffix (14+ digits) + is_collision_suffix = (len(last_part) == 8 and last_part.isalnum()) + is_timestamp_suffix = (len(last_part) >= 14 and last_part.isdigit()) + + if is_collision_suffix or is_timestamp_suffix: + file_path = os.path.join(root, filename) + rel_path = os.path.relpath(file_path, str(media_dir)) + + try: + os.remove(file_path) + deleted_count += 1 + print(f"[OK] Deleted: {rel_path}") + except Exception as e: + errors.append(f"[FAIL] Error deleting {rel_path}: {str(e)}") + print(f"[FAIL] Error deleting {rel_path}: {str(e)}") + +print("=" * 60) +print(f"\nResults:") +print(f" [OK] Successfully deleted: {deleted_count} files") + +if errors: + print(f" [FAIL] Deletion errors: {len(errors)}") + for error in errors: + print(f" {error}") +else: + print(f" [OK] No errors") + +print("\n[DONE] Cleanup completed!") diff --git a/myproject/products/management/commands/cleanup_photo_media.py b/myproject/products/management/commands/cleanup_photo_media.py new file mode 100644 index 0000000..9cb80d0 --- /dev/null +++ b/myproject/products/management/commands/cleanup_photo_media.py @@ -0,0 +1,125 @@ +""" +Management команда для очистки старых файлов фотографий. +Удаляет файлы с суффиксами (например, original_b374WLW.jpg) которые появляются при коллизии имен. +""" +import os +import logging +from django.core.management.base import BaseCommand +from django.core.files.storage import default_storage +from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Очистить старые файлы фотографий с суффиксами коллизии имен' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Показать что будет удалено без реального удаления', + ) + parser.add_argument( + '--fix-db', + action='store_true', + help='Также проверить и исправить пути в БД если нужно', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + fix_db = options['fix_db'] + + self.stdout.write(self.style.SUCCESS('=== Очистка старых файлов фотографий ===\n')) + + if dry_run: + self.stdout.write(self.style.WARNING('⚠️ РЕЖИМ ТЕСТИРОВАНИЯ (--dry-run) - ничего не будет удалено\n')) + + # Получаем все фотографии из БД + photo_models = [ProductPhoto, ProductKitPhoto, ProductCategoryPhoto] + total_photos = sum(model.objects.count() for model in photo_models) + + self.stdout.write(f'Найдено {total_photos} фотографий в БД\n') + + deleted_count = 0 + fixed_count = 0 + + # Проходим по всем фотографиям + for model_class in photo_models: + model_name = model_class.__name__ + self.stdout.write(f'\n--- {model_name} ---') + + for photo in model_class.objects.all(): + if not photo.image or not photo.image.name: + continue + + # Получаем основной путь (без расширения) + image_path = photo.image.name + base_parts = image_path.rsplit('.', 1) + base_path = base_parts[0] + + # Ищем все файлы с этим base_path + directory = os.path.dirname(image_path) + + if not directory: + continue + + try: + # Список всех файлов в папке + if hasattr(default_storage, '_storage'): + # Для файловой системы + full_dir = os.path.join(default_storage.location, directory) + if os.path.exists(full_dir): + files = os.listdir(full_dir) + else: + continue + else: + continue + + # Анализируем файлы в папке + expected_files = { + 'original.jpg', 'original.webp', 'original.png', + 'large.webp', 'medium.webp', 'thumb.webp', + } + + # Находим лишние файлы (с суффиксами) + for filename in files: + full_path = os.path.join(full_dir, filename) + file_path = os.path.join(directory, filename) + + # Проверяем есть ли суффикс коллизии (например, _b374WLW) + if filename not in expected_files: + # Это потенциальный старый файл с суффиксом + self.stdout.write(f' Найден старый файл: {file_path}') + + if not dry_run: + try: + if os.path.exists(full_path): + os.remove(full_path) + deleted_count += 1 + self.stdout.write( + self.style.WARNING(f' ✓ Удален') + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f' ✗ Ошибка при удалении: {str(e)}') + ) + else: + deleted_count += 1 + self.stdout.write( + self.style.WARNING(f' [dry-run] Будет удален') + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f' Ошибка при обработке {image_path}: {str(e)}') + ) + + # Вывод итогов + self.stdout.write(f'\n\n=== ИТОГИ ===') + self.stdout.write(self.style.SUCCESS(f'✓ Обработано файлов с суффиксами: {deleted_count}')) + + if dry_run: + self.stdout.write(self.style.WARNING( + '\n⚠️ Это был тестовый запуск. Запустите без --dry-run чтобы реально удалить файлы.' + )) diff --git a/myproject/products/utils/image_processor.py b/myproject/products/utils/image_processor.py index 3532caa..b31e820 100644 --- a/myproject/products/utils/image_processor.py +++ b/myproject/products/utils/image_processor.py @@ -235,6 +235,14 @@ class ImageProcessor: # Создаем путь: base_path/entity_id/photo_id/filename file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}" + # ВАЖНО: Удаляем старый файл если он существует, чтобы избежать коллизий имен + if default_storage.exists(file_path): + try: + default_storage.delete(file_path) + logger.info(f"Deleted old file: {file_path}") + except Exception as e: + logger.warning(f"Could not delete old file {file_path}: {str(e)}") + # Обрабатываем оригинал - делаем квадратным if size_key == 'original': max_size = ImageProcessor._get_size_dimensions('original')[0] @@ -251,8 +259,13 @@ class ImageProcessor: img_io.seek(0) # Сохраняем в хранилище + # ВАЖНО: используем name=file_path чтобы принудительно использовать это имя saved_path = default_storage.save(file_path, ContentFile(img_io.getvalue())) + # Если Django добавил суффикс (коллизия имен), логируем это + if saved_path != file_path: + logger.warning(f"File collision detected: expected {file_path}, got {saved_path}") + logger.info(f"Saved {image_format} image: {saved_path} (quality: {quality})") return saved_path