Улучшение системы работы с фото: добавлена команда очистки битых записей и оптимизация обработки изображений

This commit is contained in:
2026-01-06 09:25:37 +03:00
parent 0f19542ac9
commit 288716deba
14 changed files with 535 additions and 122 deletions

View File

@@ -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✓ Очистка завершена'))

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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. Защита от самоссылки

View File

@@ -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 всех компонентов.

View File

@@ -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()})"

View File

@@ -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):
"""

View File

@@ -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) # Первое фото главное только если еще нет главного
)
# Сохраняем файл

View File

@@ -258,11 +258,11 @@
<div class="card-body">
<div class="row g-3 catalog-list" id="catalog-grid">
{% for item in items %}
<div class="col-12 catalog-item" data-type="{{ item.item_type }}" data-category-ids="{% for cat in item.categories.all %}{{ cat.pk }}{% if not forloop.last %},{% endif %}{% endfor %}">
<div class="col-12 catalog-item" data-type="{{ item.item_type }}" data-category-ids="{% for cat in item.cached_categories %}{{ cat.pk }}{% if not forloop.last %},{% endif %}{% endfor %}">
<div class="card h-100 shadow-sm border-0">
<div class="position-relative">
{% if item.main_photo %}
<img src="{{ item.main_photo.image.url }}" class="card-img-top" alt="{{ item.name }}" style="height: 120px; object-fit: cover;">
{% if item.cached_main_photo %}
<img src="{{ item.cached_main_photo.get_thumbnail_url }}" class="card-img-top" alt="{{ item.name }}" style="height: 120px; object-fit: cover;">
{% else %}
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 120px;">
<i class="bi bi-image text-muted fs-2"></i>
@@ -359,6 +359,54 @@
</div>
{% endfor %}
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Пагинация" class="mt-4">
<ul class="pagination justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">&laquo; Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">&lsaquo; Назад</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">&laquo; Первая</span>
</li>
<li class="page-item disabled">
<span class="page-link">&lsaquo; Назад</span>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}
</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Вперед &rsaquo;</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя &raquo;</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Вперед &rsaquo;</span>
</li>
<li class="page-item disabled">
<span class="page-link">Последняя &raquo;</span>
</li>
{% endif %}
</ul>
<div class="text-center text-muted small mt-2">
Показано {{ page_obj.start_index }}{{ page_obj.end_index }} из {{ page_obj.paginator.count }} товаров
</div>
</nav>
{% endif %}
</div>
</div>
</div>

View File

@@ -47,7 +47,7 @@
style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<div class="card-body p-2 text-center">
{% if photo.order == 0 %}
{% if photo.is_main %}
<div class="badge bg-success w-100">⭐ Главное (позиция 1)</div>
{% else %}
<small class="text-muted">Позиция: {{ photo.order|add:1 }}</small>
@@ -121,7 +121,7 @@
<div class="modal-footer py-2 flex-wrap">
<div id="galleryQualityStatus" class="me-auto d-flex align-items-center gap-2">
<!-- Индикатор качества текущего фото в галерее -->
<span id="mainBadge" {% if not product_photos.0.order == 0 %}style="display: none;"{% endif %} class="badge bg-success">⭐ Главное</span>
<span id="mainBadge" {% if not product_photos.0.is_main %}style="display: none;"{% endif %} class="badge bg-success">⭐ Главное</span>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Закрыть</button>
</div>

View File

@@ -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

View File

@@ -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:
# В случае ошибки возвращаем оригинал

View File

@@ -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 только главного фото для товаров (is_main=True)
main_product_photo = Prefetch(
'photos',
queryset=ProductPhoto.objects.filter(is_main=True),
to_attr='main_photo_list'
)
# Оптимизированный 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
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
# Prefetch только главного фото для комплектов (is_main=True)
main_kit_photo = Prefetch(
'photos',
queryset=ProductKitPhoto.objects.filter(is_main=True),
to_attr='main_photo_list'
)
# Все комплекты
all_kits = ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related(
Prefetch('photos', queryset=ProductKitPhoto.objects.order_by('order'))
# Комплекты с фотографиями
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')
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
# Объединяем товары и комплекты
items_list = []
# Объединяем и сортируем
items = sorted(list(products_dict.values()) + list(kits_dict.values()), key=lambda x: x.name)
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_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

View File

@@ -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)