diff --git a/myproject/products/management/commands/cleanup_missing_photos.py b/myproject/products/management/commands/cleanup_missing_photos.py new file mode 100644 index 0000000..c0ecc40 --- /dev/null +++ b/myproject/products/management/commands/cleanup_missing_photos.py @@ -0,0 +1,179 @@ +""" +Management команда для удаления записей фотографий, файлы которых не существуют на диске. +Проверяет ProductPhoto, ProductKitPhoto, ProductCategoryPhoto. + +Использование: + # Для конкретного тенанта + python manage.py cleanup_missing_photos --schema=anatol --dry-run + python manage.py cleanup_missing_photos --schema=anatol + + # Для всех тенантов + python manage.py cleanup_missing_photos --all-tenants --dry-run + python manage.py cleanup_missing_photos --all-tenants +""" +from django.core.management.base import BaseCommand +from django.core.files.storage import default_storage +from django_tenants.utils import schema_context, get_tenant_model +from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto + + +class Command(BaseCommand): + help = 'Удаляет записи фотографий, файлы которых не существуют на диске' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Показать что будет удалено, но не удалять', + ) + parser.add_argument( + '--schema', + type=str, + help='Schema name (subdomain) тенанта для обработки', + ) + parser.add_argument( + '--all-tenants', + action='store_true', + help='Обработать все тенанты (кроме public)', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + schema_name = options.get('schema') + all_tenants = options.get('all_tenants') + + if not schema_name and not all_tenants: + self.stdout.write( + self.style.ERROR( + '\n❌ Ошибка: укажите либо --schema=<имя>, либо --all-tenants\n' + 'Примеры:\n' + ' python manage.py cleanup_missing_photos --schema=anatol --dry-run\n' + ' python manage.py cleanup_missing_photos --all-tenants\n' + ) + ) + return + + # Получаем список тенантов для обработки + Tenant = get_tenant_model() + + if all_tenants: + tenants = Tenant.objects.exclude(schema_name='public') + else: + try: + tenants = [Tenant.objects.get(schema_name=schema_name)] + except Tenant.DoesNotExist: + self.stdout.write( + self.style.ERROR(f'\n❌ Тенант с schema "{schema_name}" не найден\n') + ) + return + + # Обрабатываем каждый тенант + for tenant in tenants: + self._process_tenant(tenant, dry_run) + + def _process_tenant(self, tenant, dry_run): + """Обработка одного тенанта""" + self.stdout.write('\n' + '=' * 70) + self.stdout.write( + self.style.SUCCESS(f'Тенант: {tenant.name} (schema: {tenant.schema_name})') + ) + self.stdout.write('=' * 70) + + if dry_run: + self.stdout.write(self.style.WARNING('РЕЖИМ ПРОВЕРКИ (записи не будут удалены)\n')) + else: + self.stdout.write(self.style.WARNING('УДАЛЕНИЕ БИТЫХ ФОТОГРАФИЙ\n')) + + with schema_context(tenant.schema_name): + # Счетчики + total_checked = 0 + total_missing = 0 + total_deleted = 0 + + # Проверяем ProductPhoto + self.stdout.write('\n1. Проверка фотографий товаров (ProductPhoto)...') + product_photos = ProductPhoto.objects.select_related('product').all() + + for photo in product_photos: + total_checked += 1 + + # Проверяем существование файла + if not photo.image or not default_storage.exists(photo.image.name): + total_missing += 1 + product_name = photo.product.name if photo.product else 'N/A' + file_path = photo.image.name if photo.image else 'N/A' + + self.stdout.write( + self.style.ERROR( + f' ❌ Товар: {product_name} (ID: {photo.product_id}) - ' + f'Файл не найден: {file_path}' + ) + ) + + if not dry_run: + photo.delete() + total_deleted += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Запись удалена')) + + # Проверяем ProductKitPhoto + self.stdout.write('\n2. Проверка фотографий комплектов (ProductKitPhoto)...') + kit_photos = ProductKitPhoto.objects.select_related('kit').all() + + for photo in kit_photos: + total_checked += 1 + + if not photo.image or not default_storage.exists(photo.image.name): + total_missing += 1 + kit_name = photo.kit.name if photo.kit else 'N/A' + file_path = photo.image.name if photo.image else 'N/A' + + self.stdout.write( + self.style.ERROR( + f' ❌ Комплект: {kit_name} (ID: {photo.kit_id}) - ' + f'Файл не найден: {file_path}' + ) + ) + + if not dry_run: + photo.delete() + total_deleted += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Запись удалена')) + + # Проверяем ProductCategoryPhoto + self.stdout.write('\n3. Проверка фотографий категорий (ProductCategoryPhoto)...') + category_photos = ProductCategoryPhoto.objects.select_related('category').all() + + for photo in category_photos: + total_checked += 1 + + if not photo.image or not default_storage.exists(photo.image.name): + total_missing += 1 + category_name = photo.category.name if photo.category else 'N/A' + file_path = photo.image.name if photo.image else 'N/A' + + self.stdout.write( + self.style.ERROR( + f' ❌ Категория: {category_name} (ID: {photo.category_id}) - ' + f'Файл не найден: {file_path}' + ) + ) + + if not dry_run: + photo.delete() + total_deleted += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Запись удалена')) + + # Итоговая статистика для тенанта + self.stdout.write('\n' + '-' * 70) + self.stdout.write(self.style.SUCCESS(f'Всего проверено записей: {total_checked}')) + self.stdout.write(self.style.WARNING(f'Найдено битых записей: {total_missing}')) + + if dry_run: + self.stdout.write( + self.style.WARNING( + f'\n⚠ РЕЖИМ ПРОВЕРКИ: записи НЕ удалены' + ) + ) + else: + self.stdout.write(self.style.SUCCESS(f'Удалено записей: {total_deleted}')) + self.stdout.write(self.style.SUCCESS('\n✓ Очистка завершена')) diff --git a/myproject/products/migrations/0003_alter_productcategoryphoto_options_and_more.py b/myproject/products/migrations/0003_alter_productcategoryphoto_options_and_more.py new file mode 100644 index 0000000..413cc8d --- /dev/null +++ b/myproject/products/migrations/0003_alter_productcategoryphoto_options_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.0.10 on 2026-01-06 05:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0002_productimportjob'), + ] + + operations = [ + migrations.AlterModelOptions( + name='productcategoryphoto', + options={'ordering': ['-is_main', 'order', '-created_at'], 'verbose_name': 'Фото категории', 'verbose_name_plural': 'Фото категорий'}, + ), + migrations.AlterModelOptions( + name='productkitphoto', + options={'ordering': ['-is_main', 'order', '-created_at'], 'verbose_name': 'Фото комплекта', 'verbose_name_plural': 'Фото комплектов'}, + ), + migrations.AlterModelOptions( + name='productphoto', + options={'ordering': ['-is_main', 'order', '-created_at'], 'verbose_name': 'Фото товара', 'verbose_name_plural': 'Фото товаров'}, + ), + migrations.AddField( + model_name='productcategoryphoto', + name='is_main', + field=models.BooleanField(db_index=True, default=False, help_text='Главное фото отображается в карточках, каталоге и превью. Может быть только одно.', verbose_name='Главное фото'), + ), + migrations.AddField( + model_name='productkitphoto', + name='is_main', + field=models.BooleanField(db_index=True, default=False, help_text='Главное фото отображается в карточках, каталоге и превью. Может быть только одно.', verbose_name='Главное фото'), + ), + migrations.AddField( + model_name='productphoto', + name='is_main', + field=models.BooleanField(db_index=True, default=False, help_text='Главное фото отображается в карточках, каталоге и превью. Может быть только одно.', verbose_name='Главное фото'), + ), + migrations.AddConstraint( + model_name='productcategoryphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('category',), name='unique_main_photo_per_category'), + ), + migrations.AddConstraint( + model_name='productkitphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('kit',), name='unique_main_photo_per_kit'), + ), + migrations.AddConstraint( + model_name='productphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('product',), name='unique_main_photo_per_product'), + ), + ] diff --git a/myproject/products/migrations/0004_set_main_photo_from_order.py b/myproject/products/migrations/0004_set_main_photo_from_order.py new file mode 100644 index 0000000..b26d10e --- /dev/null +++ b/myproject/products/migrations/0004_set_main_photo_from_order.py @@ -0,0 +1,68 @@ +# Generated by Django 5.0.10 on 2026-01-06 05:03 + +from django.db import migrations + + +def set_main_photo_from_order(apps, schema_editor): + """ + Data migration: устанавливает is_main=True для фото с order=0. + + Для каждой сущности (product, kit, category) находит фото с order=0 + и устанавливает ему is_main=True. + + Если у сущности нет фото с order=0, устанавливает is_main=True для первого фото. + """ + ProductPhoto = apps.get_model('products', 'ProductPhoto') + ProductKitPhoto = apps.get_model('products', 'ProductKitPhoto') + ProductCategoryPhoto = apps.get_model('products', 'ProductCategoryPhoto') + Product = apps.get_model('products', 'Product') + ProductKit = apps.get_model('products', 'ProductKit') + ProductCategory = apps.get_model('products', 'ProductCategory') + + # Обрабатываем ProductPhoto + for product in Product.objects.all(): + photos = ProductPhoto.objects.filter(product=product).order_by('order', '-created_at') + if photos.exists(): + main_photo = photos.filter(order=0).first() or photos.first() + main_photo.is_main = True + main_photo.save(update_fields=['is_main']) + + # Обрабатываем ProductKitPhoto + for kit in ProductKit.objects.all(): + photos = ProductKitPhoto.objects.filter(kit=kit).order_by('order', '-created_at') + if photos.exists(): + main_photo = photos.filter(order=0).first() or photos.first() + main_photo.is_main = True + main_photo.save(update_fields=['is_main']) + + # Обрабатываем ProductCategoryPhoto + for category in ProductCategory.objects.all(): + photos = ProductCategoryPhoto.objects.filter(category=category).order_by('order', '-created_at') + if photos.exists(): + main_photo = photos.filter(order=0).first() or photos.first() + main_photo.is_main = True + main_photo.save(update_fields=['is_main']) + + +def reverse_main_photo(apps, schema_editor): + """ + Reverse migration: сбрасывает is_main в False для всех фото. + """ + ProductPhoto = apps.get_model('products', 'ProductPhoto') + ProductKitPhoto = apps.get_model('products', 'ProductKitPhoto') + ProductCategoryPhoto = apps.get_model('products', 'ProductCategoryPhoto') + + ProductPhoto.objects.filter(is_main=True).update(is_main=False) + ProductKitPhoto.objects.filter(is_main=True).update(is_main=False) + ProductCategoryPhoto.objects.filter(is_main=True).update(is_main=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_alter_productcategoryphoto_options_and_more'), + ] + + operations = [ + migrations.RunPython(set_main_photo_from_order, reverse_main_photo), + ] diff --git a/myproject/products/models/categories.py b/myproject/products/models/categories.py index 520a6c2..dc29b10 100644 --- a/myproject/products/models/categories.py +++ b/myproject/products/models/categories.py @@ -62,6 +62,17 @@ class ProductCategory(models.Model): def __str__(self): return self.name + @property + def main_photo(self): + """ + Главное фото категории (is_main=True). + Используется в карточках, каталоге, превью. + + Returns: + ProductCategoryPhoto | None: Главное фото или None если фото нет + """ + return self.photos.filter(is_main=True).first() + def clean(self): """Валидация категории перед сохранением""" # 1. Защита от самоссылки diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 7ee940d..70ce706 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -141,6 +141,17 @@ class ProductKit(BaseProductEntity): return self.sale_price return self.price + @property + def main_photo(self): + """ + Главное фото комплекта (is_main=True). + Используется в карточках, каталоге, превью. + + Returns: + ProductKitPhoto | None: Главное фото или None если фото нет + """ + return self.photos.filter(is_main=True).first() + def recalculate_base_price(self): """ Пересчитать сумму actual_price всех компонентов. diff --git a/myproject/products/models/photos.py b/myproject/products/models/photos.py index 66d7d2a..51e21b5 100644 --- a/myproject/products/models/photos.py +++ b/myproject/products/models/photos.py @@ -4,6 +4,7 @@ """ from abc import abstractmethod from django.db import models +from django.db.models import Q from django.utils import timezone @@ -73,14 +74,25 @@ class BasePhoto(models.Model): Паттерн: Template Method - Общие методы save(), delete() и get_*_url() определены здесь - Специфичные детали (related entity, upload path) задаются через абстрактные методы + + Главное фото: + - is_main=True определяет главное фото (используется в карточках, каталоге, превью) + - Constraint уникальности (только одно is_main=True на сущность) реализован в дочерних классах + - order используется для сортировки остальных фото """ image = models.ImageField(verbose_name="Оригинальное фото") + is_main = models.BooleanField( + default=False, + db_index=True, + verbose_name="Главное фото", + help_text="Главное фото отображается в карточках, каталоге и превью. Может быть только одно." + ) order = models.PositiveIntegerField(default=0, verbose_name="Порядок") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") class Meta: abstract = True - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] # Главное фото всегда первое @abstractmethod def get_entity(self): @@ -319,12 +331,19 @@ class ProductPhoto(BasePhoto): class Meta: verbose_name = "Фото товара" verbose_name_plural = "Фото товаров" - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] indexes = [ models.Index(fields=['quality_level']), models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning', 'product']), # Для поиска товаров требующих обновления фото ] + constraints = [ + models.UniqueConstraint( + fields=['product'], + condition=Q(is_main=True), + name='unique_main_photo_per_product' + ) + ] def __str__(self): return f"Фото для {self.product.name} ({self.get_quality_level_display()})" @@ -386,12 +405,19 @@ class ProductKitPhoto(BasePhoto): class Meta: verbose_name = "Фото комплекта" verbose_name_plural = "Фото комплектов" - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] indexes = [ models.Index(fields=['quality_level']), models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning', 'kit']), ] + constraints = [ + models.UniqueConstraint( + fields=['kit'], + condition=Q(is_main=True), + name='unique_main_photo_per_kit' + ) + ] def __str__(self): return f"Фото для {self.kit.name} ({self.get_quality_level_display()})" @@ -453,12 +479,19 @@ class ProductCategoryPhoto(BasePhoto): class Meta: verbose_name = "Фото категории" verbose_name_plural = "Фото категорий" - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] indexes = [ models.Index(fields=['quality_level']), models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning', 'category']), ] + constraints = [ + models.UniqueConstraint( + fields=['category'], + condition=Q(is_main=True), + name='unique_main_photo_per_category' + ) + ] def __str__(self): return f"Фото для {self.category.name} ({self.get_quality_level_display()})" diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index fd7a5e8..dc4d2b6 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -128,6 +128,17 @@ class Product(BaseProductEntity): """ return self.sale_price if self.sale_price else self.price + @property + def main_photo(self): + """ + Главное фото товара (is_main=True). + Используется в карточках, каталоге, превью. + + Returns: + ProductPhoto | None: Главное фото или None если фото нет + """ + return self.photos.filter(is_main=True).first() + @property def cost_price_details(self): """ diff --git a/myproject/products/services/import_export.py b/myproject/products/services/import_export.py index 8201f12..4ab6b76 100644 --- a/myproject/products/services/import_export.py +++ b/myproject/products/services/import_export.py @@ -461,13 +461,16 @@ class ProductImporter: if not urls: return + # Проверяем есть ли уже главное фото + has_main = product.photos.filter(is_main=True).exists() + # Создаём задачи для каждого URL for idx, url in enumerate(urls): - # FIX: ProductPhoto не имеет is_main, используется только order task = { 'product_id': product.id, 'url': url, - 'order': idx, # Первое фото (order=0) автоматически главное + 'order': idx, + 'is_main': (idx == 0 and not has_main) # Первое фото главное только если еще нет главного } self.photo_tasks.append(task) @@ -503,6 +506,9 @@ class ProductImporter: if not urls: return + # Проверяем есть ли уже главное фото + has_main = product.photos.filter(is_main=True).exists() + # Скачиваем и сохраняем каждое изображение for idx, url in enumerate(urls): try: @@ -515,10 +521,10 @@ class ProductImporter: filename = parsed_url.path.split('/')[-1] # Создаём ProductPhoto - # FIX: ProductPhoto не имеет is_main, используется только order photo = ProductPhoto( product=product, - order=idx # Первое фото (order=0) автоматически главное + order=idx, + is_main=(idx == 0 and not has_main) # Первое фото главное только если еще нет главного ) # Сохраняем файл diff --git a/myproject/products/templates/products/catalog.html b/myproject/products/templates/products/catalog.html index 8e0d84d..aa0f05f 100644 --- a/myproject/products/templates/products/catalog.html +++ b/myproject/products/templates/products/catalog.html @@ -258,11 +258,11 @@
{% for item in items %} -
+
- {% if item.main_photo %} - {{ item.name }} + {% if item.cached_main_photo %} + {{ item.name }} {% else %}
@@ -359,6 +359,54 @@
{% endfor %}
+ + + {% if is_paginated %} + + {% endif %}
diff --git a/myproject/products/templates/products/product_detail.html b/myproject/products/templates/products/product_detail.html index 2b2eeaa..56928ee 100644 --- a/myproject/products/templates/products/product_detail.html +++ b/myproject/products/templates/products/product_detail.html @@ -47,7 +47,7 @@ style="max-width: 100%; max-height: 100%; object-fit: contain;">
- {% if photo.order == 0 %} + {% if photo.is_main %}
⭐ Главное (позиция 1)
{% else %} Позиция: {{ photo.order|add:1 }} @@ -121,7 +121,7 @@ diff --git a/myproject/products/utils/image_processor.py b/myproject/products/utils/image_processor.py index 9498c13..c89fd17 100644 --- a/myproject/products/utils/image_processor.py +++ b/myproject/products/utils/image_processor.py @@ -159,15 +159,15 @@ class ImageProcessor: def _resize_image(img, size): """ Изменяет размер изображения с сохранением пропорций. - Если исходное изображение меньше целевого размера, добавляет белый фон. - Если больше - уменьшает с сохранением пропорций. + НЕ увеличивает маленькие изображения (сохраняет качество). + Создает адаптивный квадрат по размеру реального изображения. Args: img: PIL Image object - size: Кортеж (width, height) + size: Кортеж (width, height) - максимальный целевой размер Returns: - PIL Image object с новым размером + PIL Image object - квадратное изображение с минимальным белым фоном """ # Копируем изображение, чтобы не модифицировать оригинал img_copy = img.copy() @@ -190,12 +190,14 @@ class ImageProcessor: if img_copy.width > new_width or img_copy.height > new_height: img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS) - # Создаем новое изображение нужного размера с белым фоном - new_img = Image.new('RGB', size, (255, 255, 255)) + # Создаем адаптивный квадрат по размеру реального изображения (а не по конфигурации) + # Это позволяет избежать огромных белых полей для маленьких фото + square_size = max(img_copy.width, img_copy.height) + new_img = Image.new('RGB', (square_size, square_size), (255, 255, 255)) # Центрируем исходное изображение на белом фоне - offset_x = (size[0] - img_copy.width) // 2 - offset_y = (size[1] - img_copy.height) // 2 + offset_x = (square_size - img_copy.width) // 2 + offset_y = (square_size - img_copy.height) // 2 new_img.paste(img_copy, (offset_x, offset_y)) return new_img diff --git a/myproject/products/utils/image_service.py b/myproject/products/utils/image_service.py index fea132c..fc4d3d3 100644 --- a/myproject/products/utils/image_service.py +++ b/myproject/products/utils/image_service.py @@ -105,17 +105,16 @@ class ImageService: # Используем default_storage.url() для корректной работы с TenantAwareFileSystemStorage # Это гарантирует что URL будет содержать tenant_id если необходимо - # Проверяем существование файла - если не найден, возвращаем пустую строку - # (для обработанных файлов миниатюра должна существовать) + # Проверяем существование файла - если не найден, возвращаем оригинал как fallback if default_storage.exists(file_path): url = default_storage.url(file_path) logger.debug(f"[ImageService] Returning {size} URL: {file_path} -> {url}") return url else: - # Файл нужного размера не найден - возвращаем пустую строку - # (файл обработан, но миниатюра не создана - это ошибка) - logger.warning(f"[ImageService] {size} file not found: {file_path}, file should be processed") - return '' + # Файл нужного размера не найден - возвращаем оригинал как fallback + # (файл может быть загружен до внедрения системы обработки или обработка не завершена) + logger.warning(f"[ImageService] {size} file not found: {file_path}, using original as fallback") + return default_storage.url(str(original_image_path)) except Exception as e: # В случае ошибки возвращаем оригинал diff --git a/myproject/products/views/catalog_views.py b/myproject/products/views/catalog_views.py index 1b11f46..bb064c2 100644 --- a/myproject/products/views/catalog_views.py +++ b/myproject/products/views/catalog_views.py @@ -2,16 +2,19 @@ Представление для каталога товаров и комплектов. """ from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import TemplateView -from django.db.models import Prefetch, Sum, Value, DecimalField +from django.views.generic import ListView +from django.db.models import Prefetch, Sum, Value, DecimalField, Q from django.db.models.functions import Coalesce +from django.core.paginator import Paginator -from ..models import Product, ProductKit, ProductCategory, ProductPhoto, ProductKitPhoto +from ..models import Product, ProductKit, ProductCategory, ProductPhoto, ProductKitPhoto, KitItem -class CatalogView(LoginRequiredMixin, TemplateView): - """Каталог с деревом категорий слева и сеткой товаров справа.""" +class CatalogView(LoginRequiredMixin, ListView): + """Каталог с деревом категорий слева и сеткой товаров справа с пагинацией.""" template_name = 'products/catalog.html' + context_object_name = 'items' + paginate_by = 50 def build_category_tree(self, categories, parent=None): """Рекурсивно строит дерево категорий с товарами.""" @@ -25,94 +28,84 @@ class CatalogView(LoginRequiredMixin, TemplateView): }) return tree - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - + def get_queryset(self): + """Получаем объединенный список товаров и комплектов с оптимизацией.""" # Аннотации для остатков total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField()) total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField()) - # Оптимизированный prefetch с аннотациями для активных товаров - active_products_prefetch = Prefetch( - 'products', - queryset=Product.objects.filter(status='active').prefetch_related( - Prefetch('photos', queryset=ProductPhoto.objects.order_by('order')) - ).annotate( - total_available=total_available, - total_reserved=total_reserved, - ).order_by('name') - ) - - # Оптимизированный prefetch для комплектов - active_kits_prefetch = Prefetch( - 'kits', - queryset=ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related( - Prefetch('photos', queryset=ProductKitPhoto.objects.order_by('order')) - ).order_by('name') + # Prefetch только главного фото для товаров (is_main=True) + main_product_photo = Prefetch( + 'photos', + queryset=ProductPhoto.objects.filter(is_main=True), + to_attr='main_photo_list' ) - # Все активные категории с оптимизированным prefetch - categories = list(ProductCategory.objects.filter( - is_active=True, is_deleted=False - ).prefetch_related(active_products_prefetch, active_kits_prefetch).order_by('name')) - - # Строим дерево - category_tree = self.build_category_tree(categories, parent=None) - - # Извлекаем товары и комплекты - два способа: - # 1. Из категорий (для оптимизации prefetch) - # 2. Все активные товары напрямую (для товаров без категорий) - products_dict = {} - kits_dict = {} - - # Сначала извлекаем из категорий (используем prefetch кеш) - for cat in categories: - for p in cat.products.all(): - if p.id not in products_dict: - p.item_type = 'product' - p.main_photo = p.photos.all()[0] if p.photos.all() else None - p.total_free = p.total_available - p.total_reserved - products_dict[p.id] = p - - for k in cat.kits.all(): - if k.id not in kits_dict: - k.item_type = 'kit' - k.main_photo = k.photos.all()[0] if k.photos.all() else None - # Рассчитываем доступное количество комплектов - k.total_free = k.calculate_available_quantity() - kits_dict[k.id] = k - - # Теперь добавляем все товары, которых еще нет (товары без категорий или не загруженные) - all_products = Product.objects.filter(status='active').prefetch_related( - Prefetch('photos', queryset=ProductPhoto.objects.order_by('order')) + # Товары с фотографиями и остатками + products = Product.objects.filter(status='active').prefetch_related( + main_product_photo, + 'categories' ).annotate( total_available=total_available, total_reserved=total_reserved, ).order_by('name') - - for p in all_products: - if p.id not in products_dict: - p.item_type = 'product' - p.main_photo = p.photos.all()[0] if p.photos.all() else None - p.total_free = p.total_available - p.total_reserved - products_dict[p.id] = p - - # Все комплекты - all_kits = ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related( - Prefetch('photos', queryset=ProductKitPhoto.objects.order_by('order')) + + # Prefetch только главного фото для комплектов (is_main=True) + main_kit_photo = Prefetch( + 'photos', + queryset=ProductKitPhoto.objects.filter(is_main=True), + to_attr='main_photo_list' + ) + + # Комплекты с фотографиями + kits = ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related( + main_kit_photo, + 'categories', + Prefetch( + 'kit_items', + queryset=KitItem.objects.select_related( + 'product', 'variant_group' + ).prefetch_related('product__stocks') + ) ).order_by('name') + + # Объединяем товары и комплекты + items_list = [] - for k in all_kits: - if k.id not in kits_dict: - k.item_type = 'kit' - k.main_photo = k.photos.all()[0] if k.photos.all() else None - # Рассчитываем доступное количество комплектов - k.total_free = k.calculate_available_quantity() - kits_dict[k.id] = k + for p in products: + p.item_type = 'product' + # Используем кешированное главное фото из prefetch + p.cached_main_photo = p.main_photo_list[0] if p.main_photo_list else None + # Кешируем категории для избежания повторных запросов + p.cached_categories = list(p.categories.all()) + p.total_free = p.total_available - p.total_reserved + items_list.append(p) + + for k in kits: + k.item_type = 'kit' + # Используем кешированное главное фото из prefetch + k.cached_main_photo = k.main_photo_list[0] if k.main_photo_list else None + # Кешируем категории для избежания повторных запросов + k.cached_categories = list(k.categories.all()) + # Кешируем результат calculate_available_quantity + k.total_free = k.calculate_available_quantity() + items_list.append(k) - # Объединяем и сортируем - items = sorted(list(products_dict.values()) + list(kits_dict.values()), key=lambda x: x.name) + # Сортируем по имени + items_list.sort(key=lambda x: x.name) + + return items_list + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Все активные категории для дерева + categories = list(ProductCategory.objects.filter( + is_active=True, is_deleted=False + ).order_by('name')) + + # Строим дерево + category_tree = self.build_category_tree(categories, parent=None) context['category_tree'] = category_tree - context['items'] = items + return context diff --git a/myproject/products/views/photo_management.py b/myproject/products/views/photo_management.py index 68da6c4..ccc4f97 100644 --- a/myproject/products/views/photo_management.py +++ b/myproject/products/views/photo_management.py @@ -45,7 +45,10 @@ def generic_photo_delete(request, pk, photo_model, redirect_url_name, parent_att def generic_photo_set_main(request, pk, photo_model, redirect_url_name, parent_attr, permission): """ - Универсальная установка фото как главного (order = 0). + Универсальная установка фото как главного (is_main=True). + + Автоматически сбрасывает is_main=False у старого главного фото. + Constraint на уровне БД гарантирует, что у сущности может быть только одно is_main=True. Args: request: HTTP request @@ -64,24 +67,21 @@ def generic_photo_set_main(request, pk, photo_model, redirect_url_name, parent_a messages.error(request, 'У вас нет прав для изменения порядка фотографий.') return redirect(redirect_url_name, pk=parent_id) - # Получаем все фото этого родительского объекта - filter_kwargs = {f"{parent_attr}_id": parent_id} - photos = photo_model.objects.filter(**filter_kwargs).order_by('order') - # Если это уже главное фото, ничего не делаем - if photo.order == 0: + if photo.is_main: messages.info(request, 'Это фото уже установлено как главное.') return redirect(redirect_url_name, pk=parent_id) - # Меняем порядок: текущее главное фото становится вторым - old_order = photo.order - for p in photos: - if p.pk == photo.pk: - p.order = 0 - p.save() - elif p.order == 0: - p.order = old_order - p.save() + # Сбрасываем is_main у старого главного фото + filter_kwargs = {f"{parent_attr}_id": parent_id, 'is_main': True} + old_main = photo_model.objects.filter(**filter_kwargs).first() + if old_main: + old_main.is_main = False + old_main.save(update_fields=['is_main']) + + # Устанавливаем новое главное фото + photo.is_main = True + photo.save(update_fields=['is_main']) messages.success(request, 'Фото установлено как главное!') return redirect(redirect_url_name, pk=parent_id)