diff --git a/myproject/products/management/commands/cleanup_missing_photos.py b/myproject/products/management/commands/cleanup_missing_photos.py new file mode 100644 index 0000000..c0ecc40 --- /dev/null +++ b/myproject/products/management/commands/cleanup_missing_photos.py @@ -0,0 +1,179 @@ +""" +Management команда для удаления записей фотографий, файлы которых не существуют на диске. +Проверяет ProductPhoto, ProductKitPhoto, ProductCategoryPhoto. + +Использование: + # Для конкретного тенанта + python manage.py cleanup_missing_photos --schema=anatol --dry-run + python manage.py cleanup_missing_photos --schema=anatol + + # Для всех тенантов + python manage.py cleanup_missing_photos --all-tenants --dry-run + python manage.py cleanup_missing_photos --all-tenants +""" +from django.core.management.base import BaseCommand +from django.core.files.storage import default_storage +from django_tenants.utils import schema_context, get_tenant_model +from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto + + +class Command(BaseCommand): + help = 'Удаляет записи фотографий, файлы которых не существуют на диске' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Показать что будет удалено, но не удалять', + ) + parser.add_argument( + '--schema', + type=str, + help='Schema name (subdomain) тенанта для обработки', + ) + parser.add_argument( + '--all-tenants', + action='store_true', + help='Обработать все тенанты (кроме public)', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + schema_name = options.get('schema') + all_tenants = options.get('all_tenants') + + if not schema_name and not all_tenants: + self.stdout.write( + self.style.ERROR( + '\n❌ Ошибка: укажите либо --schema=<имя>, либо --all-tenants\n' + 'Примеры:\n' + ' python manage.py cleanup_missing_photos --schema=anatol --dry-run\n' + ' python manage.py cleanup_missing_photos --all-tenants\n' + ) + ) + return + + # Получаем список тенантов для обработки + Tenant = get_tenant_model() + + if all_tenants: + tenants = Tenant.objects.exclude(schema_name='public') + else: + try: + tenants = [Tenant.objects.get(schema_name=schema_name)] + except Tenant.DoesNotExist: + self.stdout.write( + self.style.ERROR(f'\n❌ Тенант с schema "{schema_name}" не найден\n') + ) + return + + # Обрабатываем каждый тенант + for tenant in tenants: + self._process_tenant(tenant, dry_run) + + def _process_tenant(self, tenant, dry_run): + """Обработка одного тенанта""" + self.stdout.write('\n' + '=' * 70) + self.stdout.write( + self.style.SUCCESS(f'Тенант: {tenant.name} (schema: {tenant.schema_name})') + ) + self.stdout.write('=' * 70) + + if dry_run: + self.stdout.write(self.style.WARNING('РЕЖИМ ПРОВЕРКИ (записи не будут удалены)\n')) + else: + self.stdout.write(self.style.WARNING('УДАЛЕНИЕ БИТЫХ ФОТОГРАФИЙ\n')) + + with schema_context(tenant.schema_name): + # Счетчики + total_checked = 0 + total_missing = 0 + total_deleted = 0 + + # Проверяем ProductPhoto + self.stdout.write('\n1. Проверка фотографий товаров (ProductPhoto)...') + product_photos = ProductPhoto.objects.select_related('product').all() + + for photo in product_photos: + total_checked += 1 + + # Проверяем существование файла + if not photo.image or not default_storage.exists(photo.image.name): + total_missing += 1 + product_name = photo.product.name if photo.product else 'N/A' + file_path = photo.image.name if photo.image else 'N/A' + + self.stdout.write( + self.style.ERROR( + f' ❌ Товар: {product_name} (ID: {photo.product_id}) - ' + f'Файл не найден: {file_path}' + ) + ) + + if not dry_run: + photo.delete() + total_deleted += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Запись удалена')) + + # Проверяем ProductKitPhoto + self.stdout.write('\n2. Проверка фотографий комплектов (ProductKitPhoto)...') + kit_photos = ProductKitPhoto.objects.select_related('kit').all() + + for photo in kit_photos: + total_checked += 1 + + if not photo.image or not default_storage.exists(photo.image.name): + total_missing += 1 + kit_name = photo.kit.name if photo.kit else 'N/A' + file_path = photo.image.name if photo.image else 'N/A' + + self.stdout.write( + self.style.ERROR( + f' ❌ Комплект: {kit_name} (ID: {photo.kit_id}) - ' + f'Файл не найден: {file_path}' + ) + ) + + if not dry_run: + photo.delete() + total_deleted += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Запись удалена')) + + # Проверяем ProductCategoryPhoto + self.stdout.write('\n3. Проверка фотографий категорий (ProductCategoryPhoto)...') + category_photos = ProductCategoryPhoto.objects.select_related('category').all() + + for photo in category_photos: + total_checked += 1 + + if not photo.image or not default_storage.exists(photo.image.name): + total_missing += 1 + category_name = photo.category.name if photo.category else 'N/A' + file_path = photo.image.name if photo.image else 'N/A' + + self.stdout.write( + self.style.ERROR( + f' ❌ Категория: {category_name} (ID: {photo.category_id}) - ' + f'Файл не найден: {file_path}' + ) + ) + + if not dry_run: + photo.delete() + total_deleted += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ Запись удалена')) + + # Итоговая статистика для тенанта + self.stdout.write('\n' + '-' * 70) + self.stdout.write(self.style.SUCCESS(f'Всего проверено записей: {total_checked}')) + self.stdout.write(self.style.WARNING(f'Найдено битых записей: {total_missing}')) + + if dry_run: + self.stdout.write( + self.style.WARNING( + f'\n⚠ РЕЖИМ ПРОВЕРКИ: записи НЕ удалены' + ) + ) + else: + self.stdout.write(self.style.SUCCESS(f'Удалено записей: {total_deleted}')) + self.stdout.write(self.style.SUCCESS('\n✓ Очистка завершена')) diff --git a/myproject/products/migrations/0003_alter_productcategoryphoto_options_and_more.py b/myproject/products/migrations/0003_alter_productcategoryphoto_options_and_more.py new file mode 100644 index 0000000..413cc8d --- /dev/null +++ b/myproject/products/migrations/0003_alter_productcategoryphoto_options_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.0.10 on 2026-01-06 05:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0002_productimportjob'), + ] + + operations = [ + migrations.AlterModelOptions( + name='productcategoryphoto', + options={'ordering': ['-is_main', 'order', '-created_at'], 'verbose_name': 'Фото категории', 'verbose_name_plural': 'Фото категорий'}, + ), + migrations.AlterModelOptions( + name='productkitphoto', + options={'ordering': ['-is_main', 'order', '-created_at'], 'verbose_name': 'Фото комплекта', 'verbose_name_plural': 'Фото комплектов'}, + ), + migrations.AlterModelOptions( + name='productphoto', + options={'ordering': ['-is_main', 'order', '-created_at'], 'verbose_name': 'Фото товара', 'verbose_name_plural': 'Фото товаров'}, + ), + migrations.AddField( + model_name='productcategoryphoto', + name='is_main', + field=models.BooleanField(db_index=True, default=False, help_text='Главное фото отображается в карточках, каталоге и превью. Может быть только одно.', verbose_name='Главное фото'), + ), + migrations.AddField( + model_name='productkitphoto', + name='is_main', + field=models.BooleanField(db_index=True, default=False, help_text='Главное фото отображается в карточках, каталоге и превью. Может быть только одно.', verbose_name='Главное фото'), + ), + migrations.AddField( + model_name='productphoto', + name='is_main', + field=models.BooleanField(db_index=True, default=False, help_text='Главное фото отображается в карточках, каталоге и превью. Может быть только одно.', verbose_name='Главное фото'), + ), + migrations.AddConstraint( + model_name='productcategoryphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('category',), name='unique_main_photo_per_category'), + ), + migrations.AddConstraint( + model_name='productkitphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('kit',), name='unique_main_photo_per_kit'), + ), + migrations.AddConstraint( + model_name='productphoto', + constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('product',), name='unique_main_photo_per_product'), + ), + ] diff --git a/myproject/products/migrations/0004_set_main_photo_from_order.py b/myproject/products/migrations/0004_set_main_photo_from_order.py new file mode 100644 index 0000000..b26d10e --- /dev/null +++ b/myproject/products/migrations/0004_set_main_photo_from_order.py @@ -0,0 +1,68 @@ +# Generated by Django 5.0.10 on 2026-01-06 05:03 + +from django.db import migrations + + +def set_main_photo_from_order(apps, schema_editor): + """ + Data migration: устанавливает is_main=True для фото с order=0. + + Для каждой сущности (product, kit, category) находит фото с order=0 + и устанавливает ему is_main=True. + + Если у сущности нет фото с order=0, устанавливает is_main=True для первого фото. + """ + ProductPhoto = apps.get_model('products', 'ProductPhoto') + ProductKitPhoto = apps.get_model('products', 'ProductKitPhoto') + ProductCategoryPhoto = apps.get_model('products', 'ProductCategoryPhoto') + Product = apps.get_model('products', 'Product') + ProductKit = apps.get_model('products', 'ProductKit') + ProductCategory = apps.get_model('products', 'ProductCategory') + + # Обрабатываем ProductPhoto + for product in Product.objects.all(): + photos = ProductPhoto.objects.filter(product=product).order_by('order', '-created_at') + if photos.exists(): + main_photo = photos.filter(order=0).first() or photos.first() + main_photo.is_main = True + main_photo.save(update_fields=['is_main']) + + # Обрабатываем ProductKitPhoto + for kit in ProductKit.objects.all(): + photos = ProductKitPhoto.objects.filter(kit=kit).order_by('order', '-created_at') + if photos.exists(): + main_photo = photos.filter(order=0).first() or photos.first() + main_photo.is_main = True + main_photo.save(update_fields=['is_main']) + + # Обрабатываем ProductCategoryPhoto + for category in ProductCategory.objects.all(): + photos = ProductCategoryPhoto.objects.filter(category=category).order_by('order', '-created_at') + if photos.exists(): + main_photo = photos.filter(order=0).first() or photos.first() + main_photo.is_main = True + main_photo.save(update_fields=['is_main']) + + +def reverse_main_photo(apps, schema_editor): + """ + Reverse migration: сбрасывает is_main в False для всех фото. + """ + ProductPhoto = apps.get_model('products', 'ProductPhoto') + ProductKitPhoto = apps.get_model('products', 'ProductKitPhoto') + ProductCategoryPhoto = apps.get_model('products', 'ProductCategoryPhoto') + + ProductPhoto.objects.filter(is_main=True).update(is_main=False) + ProductKitPhoto.objects.filter(is_main=True).update(is_main=False) + ProductCategoryPhoto.objects.filter(is_main=True).update(is_main=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_alter_productcategoryphoto_options_and_more'), + ] + + operations = [ + migrations.RunPython(set_main_photo_from_order, reverse_main_photo), + ] diff --git a/myproject/products/models/categories.py b/myproject/products/models/categories.py index 520a6c2..dc29b10 100644 --- a/myproject/products/models/categories.py +++ b/myproject/products/models/categories.py @@ -62,6 +62,17 @@ class ProductCategory(models.Model): def __str__(self): return self.name + @property + def main_photo(self): + """ + Главное фото категории (is_main=True). + Используется в карточках, каталоге, превью. + + Returns: + ProductCategoryPhoto | None: Главное фото или None если фото нет + """ + return self.photos.filter(is_main=True).first() + def clean(self): """Валидация категории перед сохранением""" # 1. Защита от самоссылки diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 7ee940d..70ce706 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -141,6 +141,17 @@ class ProductKit(BaseProductEntity): return self.sale_price return self.price + @property + def main_photo(self): + """ + Главное фото комплекта (is_main=True). + Используется в карточках, каталоге, превью. + + Returns: + ProductKitPhoto | None: Главное фото или None если фото нет + """ + return self.photos.filter(is_main=True).first() + def recalculate_base_price(self): """ Пересчитать сумму actual_price всех компонентов. diff --git a/myproject/products/models/photos.py b/myproject/products/models/photos.py index 66d7d2a..51e21b5 100644 --- a/myproject/products/models/photos.py +++ b/myproject/products/models/photos.py @@ -4,6 +4,7 @@ """ from abc import abstractmethod from django.db import models +from django.db.models import Q from django.utils import timezone @@ -73,14 +74,25 @@ class BasePhoto(models.Model): Паттерн: Template Method - Общие методы save(), delete() и get_*_url() определены здесь - Специфичные детали (related entity, upload path) задаются через абстрактные методы + + Главное фото: + - is_main=True определяет главное фото (используется в карточках, каталоге, превью) + - Constraint уникальности (только одно is_main=True на сущность) реализован в дочерних классах + - order используется для сортировки остальных фото """ image = models.ImageField(verbose_name="Оригинальное фото") + is_main = models.BooleanField( + default=False, + db_index=True, + verbose_name="Главное фото", + help_text="Главное фото отображается в карточках, каталоге и превью. Может быть только одно." + ) order = models.PositiveIntegerField(default=0, verbose_name="Порядок") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") class Meta: abstract = True - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] # Главное фото всегда первое @abstractmethod def get_entity(self): @@ -319,12 +331,19 @@ class ProductPhoto(BasePhoto): class Meta: verbose_name = "Фото товара" verbose_name_plural = "Фото товаров" - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] indexes = [ models.Index(fields=['quality_level']), models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning', 'product']), # Для поиска товаров требующих обновления фото ] + constraints = [ + models.UniqueConstraint( + fields=['product'], + condition=Q(is_main=True), + name='unique_main_photo_per_product' + ) + ] def __str__(self): return f"Фото для {self.product.name} ({self.get_quality_level_display()})" @@ -386,12 +405,19 @@ class ProductKitPhoto(BasePhoto): class Meta: verbose_name = "Фото комплекта" verbose_name_plural = "Фото комплектов" - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] indexes = [ models.Index(fields=['quality_level']), models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning', 'kit']), ] + constraints = [ + models.UniqueConstraint( + fields=['kit'], + condition=Q(is_main=True), + name='unique_main_photo_per_kit' + ) + ] def __str__(self): return f"Фото для {self.kit.name} ({self.get_quality_level_display()})" @@ -453,12 +479,19 @@ class ProductCategoryPhoto(BasePhoto): class Meta: verbose_name = "Фото категории" verbose_name_plural = "Фото категорий" - ordering = ['order', '-created_at'] + ordering = ['-is_main', 'order', '-created_at'] indexes = [ models.Index(fields=['quality_level']), models.Index(fields=['quality_warning']), models.Index(fields=['quality_warning', 'category']), ] + constraints = [ + models.UniqueConstraint( + fields=['category'], + condition=Q(is_main=True), + name='unique_main_photo_per_category' + ) + ] def __str__(self): return f"Фото для {self.category.name} ({self.get_quality_level_display()})" diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index fd7a5e8..dc4d2b6 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -128,6 +128,17 @@ class Product(BaseProductEntity): """ return self.sale_price if self.sale_price else self.price + @property + def main_photo(self): + """ + Главное фото товара (is_main=True). + Используется в карточках, каталоге, превью. + + Returns: + ProductPhoto | None: Главное фото или None если фото нет + """ + return self.photos.filter(is_main=True).first() + @property def cost_price_details(self): """ diff --git a/myproject/products/services/import_export.py b/myproject/products/services/import_export.py index 8201f12..4ab6b76 100644 --- a/myproject/products/services/import_export.py +++ b/myproject/products/services/import_export.py @@ -461,13 +461,16 @@ class ProductImporter: if not urls: return + # Проверяем есть ли уже главное фото + has_main = product.photos.filter(is_main=True).exists() + # Создаём задачи для каждого URL for idx, url in enumerate(urls): - # FIX: ProductPhoto не имеет is_main, используется только order task = { 'product_id': product.id, 'url': url, - 'order': idx, # Первое фото (order=0) автоматически главное + 'order': idx, + 'is_main': (idx == 0 and not has_main) # Первое фото главное только если еще нет главного } self.photo_tasks.append(task) @@ -503,6 +506,9 @@ class ProductImporter: if not urls: return + # Проверяем есть ли уже главное фото + has_main = product.photos.filter(is_main=True).exists() + # Скачиваем и сохраняем каждое изображение for idx, url in enumerate(urls): try: @@ -515,10 +521,10 @@ class ProductImporter: filename = parsed_url.path.split('/')[-1] # Создаём ProductPhoto - # FIX: ProductPhoto не имеет is_main, используется только order photo = ProductPhoto( product=product, - order=idx # Первое фото (order=0) автоматически главное + order=idx, + is_main=(idx == 0 and not has_main) # Первое фото главное только если еще нет главного ) # Сохраняем файл diff --git a/myproject/products/templates/products/catalog.html b/myproject/products/templates/products/catalog.html index 8e0d84d..aa0f05f 100644 --- a/myproject/products/templates/products/catalog.html +++ b/myproject/products/templates/products/catalog.html @@ -258,11 +258,11 @@