Улучшение системы работы с фото: добавлена команда очистки битых записей и оптимизация обработки изображений
This commit is contained in:
179
myproject/products/management/commands/cleanup_missing_photos.py
Normal file
179
myproject/products/management/commands/cleanup_missing_photos.py
Normal 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✓ Очистка завершена'))
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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. Защита от самоссылки
|
||||
|
||||
@@ -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 всех компонентов.
|
||||
|
||||
@@ -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()})"
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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) # Первое фото главное только если еще нет главного
|
||||
)
|
||||
|
||||
# Сохраняем файл
|
||||
|
||||
@@ -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">« Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">‹ Назад</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">« Первая</span>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">‹ Назад</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 }}">Вперед ›</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя »</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Вперед ›</span>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Последняя »</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
# В случае ошибки возвращаем оригинал
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user