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

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): def __str__(self):
return self.name 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): def clean(self):
"""Валидация категории перед сохранением""" """Валидация категории перед сохранением"""
# 1. Защита от самоссылки # 1. Защита от самоссылки

View File

@@ -141,6 +141,17 @@ class ProductKit(BaseProductEntity):
return self.sale_price return self.sale_price
return self.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): def recalculate_base_price(self):
""" """
Пересчитать сумму actual_price всех компонентов. Пересчитать сумму actual_price всех компонентов.

View File

@@ -4,6 +4,7 @@
""" """
from abc import abstractmethod from abc import abstractmethod
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -73,14 +74,25 @@ class BasePhoto(models.Model):
Паттерн: Template Method Паттерн: Template Method
- Общие методы save(), delete() и get_*_url() определены здесь - Общие методы save(), delete() и get_*_url() определены здесь
- Специфичные детали (related entity, upload path) задаются через абстрактные методы - Специфичные детали (related entity, upload path) задаются через абстрактные методы
Главное фото:
- is_main=True определяет главное фото (используется в карточках, каталоге, превью)
- Constraint уникальности (только одно is_main=True на сущность) реализован в дочерних классах
- order используется для сортировки остальных фото
""" """
image = models.ImageField(verbose_name="Оригинальное фото") 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="Порядок") order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta: class Meta:
abstract = True abstract = True
ordering = ['order', '-created_at'] ordering = ['-is_main', 'order', '-created_at'] # Главное фото всегда первое
@abstractmethod @abstractmethod
def get_entity(self): def get_entity(self):
@@ -319,12 +331,19 @@ class ProductPhoto(BasePhoto):
class Meta: class Meta:
verbose_name = "Фото товара" verbose_name = "Фото товара"
verbose_name_plural = "Фото товаров" verbose_name_plural = "Фото товаров"
ordering = ['order', '-created_at'] ordering = ['-is_main', 'order', '-created_at']
indexes = [ indexes = [
models.Index(fields=['quality_level']), models.Index(fields=['quality_level']),
models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning']),
models.Index(fields=['quality_warning', 'product']), # Для поиска товаров требующих обновления фото 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): def __str__(self):
return f"Фото для {self.product.name} ({self.get_quality_level_display()})" return f"Фото для {self.product.name} ({self.get_quality_level_display()})"
@@ -386,12 +405,19 @@ class ProductKitPhoto(BasePhoto):
class Meta: class Meta:
verbose_name = "Фото комплекта" verbose_name = "Фото комплекта"
verbose_name_plural = "Фото комплектов" verbose_name_plural = "Фото комплектов"
ordering = ['order', '-created_at'] ordering = ['-is_main', 'order', '-created_at']
indexes = [ indexes = [
models.Index(fields=['quality_level']), models.Index(fields=['quality_level']),
models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning']),
models.Index(fields=['quality_warning', 'kit']), 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): def __str__(self):
return f"Фото для {self.kit.name} ({self.get_quality_level_display()})" return f"Фото для {self.kit.name} ({self.get_quality_level_display()})"
@@ -453,12 +479,19 @@ class ProductCategoryPhoto(BasePhoto):
class Meta: class Meta:
verbose_name = "Фото категории" verbose_name = "Фото категории"
verbose_name_plural = "Фото категорий" verbose_name_plural = "Фото категорий"
ordering = ['order', '-created_at'] ordering = ['-is_main', 'order', '-created_at']
indexes = [ indexes = [
models.Index(fields=['quality_level']), models.Index(fields=['quality_level']),
models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning']),
models.Index(fields=['quality_warning', 'category']), 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): def __str__(self):
return f"Фото для {self.category.name} ({self.get_quality_level_display()})" 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 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 @property
def cost_price_details(self): def cost_price_details(self):
""" """

View File

@@ -461,13 +461,16 @@ class ProductImporter:
if not urls: if not urls:
return return
# Проверяем есть ли уже главное фото
has_main = product.photos.filter(is_main=True).exists()
# Создаём задачи для каждого URL # Создаём задачи для каждого URL
for idx, url in enumerate(urls): for idx, url in enumerate(urls):
# FIX: ProductPhoto не имеет is_main, используется только order
task = { task = {
'product_id': product.id, 'product_id': product.id,
'url': url, 'url': url,
'order': idx, # Первое фото (order=0) автоматически главное 'order': idx,
'is_main': (idx == 0 and not has_main) # Первое фото главное только если еще нет главного
} }
self.photo_tasks.append(task) self.photo_tasks.append(task)
@@ -503,6 +506,9 @@ class ProductImporter:
if not urls: if not urls:
return return
# Проверяем есть ли уже главное фото
has_main = product.photos.filter(is_main=True).exists()
# Скачиваем и сохраняем каждое изображение # Скачиваем и сохраняем каждое изображение
for idx, url in enumerate(urls): for idx, url in enumerate(urls):
try: try:
@@ -515,10 +521,10 @@ class ProductImporter:
filename = parsed_url.path.split('/')[-1] filename = parsed_url.path.split('/')[-1]
# Создаём ProductPhoto # Создаём ProductPhoto
# FIX: ProductPhoto не имеет is_main, используется только order
photo = ProductPhoto( photo = ProductPhoto(
product=product, 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="card-body">
<div class="row g-3 catalog-list" id="catalog-grid"> <div class="row g-3 catalog-list" id="catalog-grid">
{% for item in items %} {% 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="card h-100 shadow-sm border-0">
<div class="position-relative"> <div class="position-relative">
{% if item.main_photo %} {% if item.cached_main_photo %}
<img src="{{ item.main_photo.image.url }}" class="card-img-top" alt="{{ item.name }}" style="height: 120px; object-fit: cover;"> <img src="{{ item.cached_main_photo.get_thumbnail_url }}" class="card-img-top" alt="{{ item.name }}" style="height: 120px; object-fit: cover;">
{% else %} {% else %}
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 120px;"> <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> <i class="bi bi-image text-muted fs-2"></i>
@@ -359,6 +359,54 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </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> </div>
</div> </div>

View File

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

View File

@@ -159,15 +159,15 @@ class ImageProcessor:
def _resize_image(img, size): def _resize_image(img, size):
""" """
Изменяет размер изображения с сохранением пропорций. Изменяет размер изображения с сохранением пропорций.
Если исходное изображение меньше целевого размера, добавляет белый фон. НЕ увеличивает маленькие изображения (сохраняет качество).
Если больше - уменьшает с сохранением пропорций. Создает адаптивный квадрат по размеру реального изображения.
Args: Args:
img: PIL Image object img: PIL Image object
size: Кортеж (width, height) size: Кортеж (width, height) - максимальный целевой размер
Returns: Returns:
PIL Image object с новым размером PIL Image object - квадратное изображение с минимальным белым фоном
""" """
# Копируем изображение, чтобы не модифицировать оригинал # Копируем изображение, чтобы не модифицировать оригинал
img_copy = img.copy() img_copy = img.copy()
@@ -190,12 +190,14 @@ class ImageProcessor:
if img_copy.width > new_width or img_copy.height > new_height: if img_copy.width > new_width or img_copy.height > new_height:
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS) 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_x = (square_size - img_copy.width) // 2
offset_y = (size[1] - img_copy.height) // 2 offset_y = (square_size - img_copy.height) // 2
new_img.paste(img_copy, (offset_x, offset_y)) new_img.paste(img_copy, (offset_x, offset_y))
return new_img return new_img

View File

@@ -105,17 +105,16 @@ class ImageService:
# Используем default_storage.url() для корректной работы с TenantAwareFileSystemStorage # Используем default_storage.url() для корректной работы с TenantAwareFileSystemStorage
# Это гарантирует что URL будет содержать tenant_id если необходимо # Это гарантирует что URL будет содержать tenant_id если необходимо
# Проверяем существование файла - если не найден, возвращаем пустую строку # Проверяем существование файла - если не найден, возвращаем оригинал как fallback
# (для обработанных файлов миниатюра должна существовать)
if default_storage.exists(file_path): if default_storage.exists(file_path):
url = default_storage.url(file_path) url = default_storage.url(file_path)
logger.debug(f"[ImageService] Returning {size} URL: {file_path} -> {url}") logger.debug(f"[ImageService] Returning {size} URL: {file_path} -> {url}")
return url return url
else: else:
# Файл нужного размера не найден - возвращаем пустую строку # Файл нужного размера не найден - возвращаем оригинал как fallback
# (файл обработан, но миниатюра не создана - это ошибка) # (файл может быть загружен до внедрения системы обработки или обработка не завершена)
logger.warning(f"[ImageService] {size} file not found: {file_path}, file should be processed") logger.warning(f"[ImageService] {size} file not found: {file_path}, using original as fallback")
return '' return default_storage.url(str(original_image_path))
except Exception as e: except Exception as e:
# В случае ошибки возвращаем оригинал # В случае ошибки возвращаем оригинал

View File

@@ -2,16 +2,19 @@
Представление для каталога товаров и комплектов. Представление для каталога товаров и комплектов.
""" """
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.views.generic import ListView
from django.db.models import Prefetch, Sum, Value, DecimalField from django.db.models import Prefetch, Sum, Value, DecimalField, Q
from django.db.models.functions import Coalesce 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' template_name = 'products/catalog.html'
context_object_name = 'items'
paginate_by = 50
def build_category_tree(self, categories, parent=None): def build_category_tree(self, categories, parent=None):
"""Рекурсивно строит дерево категорий с товарами.""" """Рекурсивно строит дерево категорий с товарами."""
@@ -25,94 +28,84 @@ class CatalogView(LoginRequiredMixin, TemplateView):
}) })
return tree return tree
def get_context_data(self, **kwargs): def get_queryset(self):
context = super().get_context_data(**kwargs) """Получаем объединенный список товаров и комплектов с оптимизацией."""
# Аннотации для остатков # Аннотации для остатков
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField()) total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField()) total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
# Оптимизированный prefetch с аннотациями для активных товаров # Prefetch только главного фото для товаров (is_main=True)
active_products_prefetch = Prefetch( main_product_photo = Prefetch(
'products', 'photos',
queryset=Product.objects.filter(status='active').prefetch_related( queryset=ProductPhoto.objects.filter(is_main=True),
Prefetch('photos', queryset=ProductPhoto.objects.order_by('order')) to_attr='main_photo_list'
).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 # Товары с фотографиями и остатками
categories = list(ProductCategory.objects.filter( products = Product.objects.filter(status='active').prefetch_related(
is_active=True, is_deleted=False main_product_photo,
).prefetch_related(active_products_prefetch, active_kits_prefetch).order_by('name')) 'categories'
# Строим дерево
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'))
).annotate( ).annotate(
total_available=total_available, total_available=total_available,
total_reserved=total_reserved, total_reserved=total_reserved,
).order_by('name') ).order_by('name')
for p in all_products: # Prefetch только главного фото для комплектов (is_main=True)
if p.id not in products_dict: main_kit_photo = Prefetch(
p.item_type = 'product' 'photos',
p.main_photo = p.photos.all()[0] if p.photos.all() else None queryset=ProductKitPhoto.objects.filter(is_main=True),
p.total_free = p.total_available - p.total_reserved to_attr='main_photo_list'
products_dict[p.id] = p )
# Все комплекты # Комплекты с фотографиями
all_kits = ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related( kits = ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related(
Prefetch('photos', queryset=ProductKitPhoto.objects.order_by('order')) main_kit_photo,
'categories',
Prefetch(
'kit_items',
queryset=KitItem.objects.select_related(
'product', 'variant_group'
).prefetch_related('product__stocks')
)
).order_by('name') ).order_by('name')
# Объединяем товары и комплекты
items_list = []
for k in all_kits: for p in products:
if k.id not in kits_dict: p.item_type = 'product'
k.item_type = 'kit' # Используем кешированное главное фото из prefetch
k.main_photo = k.photos.all()[0] if k.photos.all() else None p.cached_main_photo = p.main_photo_list[0] if p.main_photo_list else None
# Рассчитываем доступное количество комплектов # Кешируем категории для избежания повторных запросов
k.total_free = k.calculate_available_quantity() p.cached_categories = list(p.categories.all())
kits_dict[k.id] = k 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['category_tree'] = category_tree
context['items'] = items
return context 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): 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: Args:
request: HTTP request request: HTTP request
@@ -64,24 +67,21 @@ def generic_photo_set_main(request, pk, photo_model, redirect_url_name, parent_a
messages.error(request, 'У вас нет прав для изменения порядка фотографий.') messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect(redirect_url_name, pk=parent_id) 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, 'Это фото уже установлено как главное.') messages.info(request, 'Это фото уже установлено как главное.')
return redirect(redirect_url_name, pk=parent_id) return redirect(redirect_url_name, pk=parent_id)
# Меняем порядок: текущее главное фото становится вторым # Сбрасываем is_main у старого главного фото
old_order = photo.order filter_kwargs = {f"{parent_attr}_id": parent_id, 'is_main': True}
for p in photos: old_main = photo_model.objects.filter(**filter_kwargs).first()
if p.pk == photo.pk: if old_main:
p.order = 0 old_main.is_main = False
p.save() old_main.save(update_fields=['is_main'])
elif p.order == 0:
p.order = old_order # Устанавливаем новое главное фото
p.save() photo.is_main = True
photo.save(update_fields=['is_main'])
messages.success(request, 'Фото установлено как главное!') messages.success(request, 'Фото установлено как главное!')
return redirect(redirect_url_name, pk=parent_id) return redirect(redirect_url_name, pk=parent_id)