diff --git a/myproject/products/admin.py b/myproject/products/admin.py index 3b8ac3d..77cae34 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -1,11 +1,159 @@ from django.contrib import admin from django.utils.html import format_html +from django.utils import timezone import nested_admin from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto from .models import ProductVariantGroup, KitItemPriority, SKUCounter +class DeletedFilter(admin.SimpleListFilter): + """Фильтр для отображения удаленных/активных элементов""" + title = 'Статус удаления' + parameter_name = 'is_deleted' + + def lookups(self, request, model_admin): + return ( + ('0', 'Активные'), + ('1', 'Удаленные'), + ) + + def queryset(self, request, queryset): + # queryset уже содержит всё (включая удаленные) благодаря get_queryset() + if self.value() == '0': + return queryset.filter(is_deleted=False) + elif self.value() == '1': + return queryset.filter(is_deleted=True) + return queryset + + +def restore_items(modeladmin, request, queryset): + """Action для восстановления удаленных элементов""" + updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None) + modeladmin.message_user(request, f'✓ Восстановлено {updated} элемент(ов).') +restore_items.short_description = '✓ Восстановить выбранные элементы' + + +def delete_selected(modeladmin, request, queryset): + """ + Переопределенный action удаления который вызывает .delete() на каждый объект + чтобы гарантировать мягкое удаление вместо hard delete + """ + count = 0 + for obj in queryset: + obj.delete() # Вызывает delete() метод модели (мягкое удаление) + count += 1 + modeladmin.message_user(request, f'✓ Удалено {count} элемент(ов).') +delete_selected.short_description = '🗑️ Удалить выбранные элементы' + + +def hard_delete_selected(modeladmin, request, queryset): + """ + Action для БЕЗОПАСНОГО полного удаления из БД. + Удаляет объект только если у него нет опасных связей. + + БЕЗОПАСНЫЕ связи (удаляются вместе с объектом): + - photos: фотографии объекта (создаются только для этого объекта) + + ОПАСНЫЕ связи (блокируют удаление): + - kit_items: товар используется в составе комплекта + - kit_items_direct: товар включен как компонент комплекта + - ManyToMany: товар связан с другими объектами + """ + from django.contrib import messages + + items_to_delete = [] + items_with_relations = [] + + # Связи которые БЕЗОПАСНО удалять вместе с объектом (они зависят от объекта) + SAFE_RELATIONS = {'photos'} + + for obj in queryset: + # Проверяем есть ли опасные связанные объекты + try: + has_dangerous_relations = False + dangerous_relation_details = [] + + for relation in obj._meta.get_fields(): + # Пропускаем обычные поля + if not hasattr(relation, 'related_model'): + continue + + # Для reverse relations получаем count + if hasattr(relation, 'get_accessor_name'): + try: + accessor_name = relation.get_accessor_name() + + # Пропускаем безопасные связи (удалятся при cascade delete) + if accessor_name in SAFE_RELATIONS: + continue + + related_manager = getattr(obj, accessor_name, None) + if related_manager and hasattr(related_manager, 'count'): + count = related_manager.count() + if count > 0: + has_dangerous_relations = True + relation_name = relation.related_model.__name__ + dangerous_relation_details.append(f"{relation_name} ({count})") + except (AttributeError, Exception): + # Пропускаем ошибки доступа к связям + pass + + if has_dangerous_relations: + details_str = ', '.join(dangerous_relation_details) + items_with_relations.append(f"{str(obj)} [{details_str}]") + else: + items_to_delete.append(obj) + except Exception as e: + # На случай любой неожиданной ошибки при проверке + items_with_relations.append(f"{str(obj)} (ошибка проверки: {str(e)})") + + # Удаляем безопасные элементы + deleted_count = 0 + for obj in items_to_delete: + try: + # ВАЖНО: Сначала явно удаляем все фотографии + # Это гарантирует что delete() метод каждого фото вызовется + # и удалит файлы из media/ папки + # Нужно для всех моделей которые имеют 'photos' связь + + # Для Product/ProductKit/ProductCategory - есть .photos + if hasattr(obj, 'photos'): + photos = list(obj.photos.all()) # Получаем копию списка перед удалением + for photo in photos: + photo.delete() # Вызывает ProductPhoto.delete() который удалит файлы + + # Потом удаляем сам объект + obj.hard_delete() + deleted_count += 1 + except Exception as e: + items_with_relations.append(f"{str(obj)} (ошибка удаления: {str(e)})") + + # Выводим результаты + if deleted_count > 0: + modeladmin.message_user( + request, + f'✓ Безопасно удалено {deleted_count} элемент(ов) (с фотографиями из media папки).', + messages.SUCCESS + ) + + if items_with_relations: + error_msg = f'⚠️ Не удалось удалить {len(items_with_relations)} элемент(ов) (имеют зависимые связи):\n' + '\n'.join(f' • {item}' for item in items_with_relations) + modeladmin.message_user(request, error_msg, messages.WARNING) + +hard_delete_selected.short_description = '🔴 Безопасно удалить навсегда (только без связей)' + + +def disable_delete_selected(admin_class): + """ + Декоратор для отключения стандартного Django delete_selected action. + Наш custom delete_selected будет использоваться вместо него. + """ + admin_class.actions = [action for action in admin_class.actions + if action.__name__ != 'delete_selected'] + return admin_class + + @admin.register(ProductVariantGroup) class ProductVariantGroupAdmin(admin.ModelAdmin): list_display = ['name', 'get_products_count', 'created_at'] @@ -19,11 +167,29 @@ class ProductVariantGroupAdmin(admin.ModelAdmin): class ProductCategoryAdmin(admin.ModelAdmin): - list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active') - list_filter = ('is_active', 'parent') + list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status') + list_filter = (DeletedFilter, 'is_active', 'parent') prepopulated_fields = {'slug': ('name',)} search_fields = ('name', 'sku') - readonly_fields = ('photo_preview_large',) + readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') + actions = [restore_items, delete_selected, hard_delete_selected] + + def get_queryset(self, request): + """Переопределяем queryset для доступа ко всем категориям (включая удаленные)""" + qs = ProductCategory.all_objects.all() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_deleted_status(self, obj): + """Показывает статус удаления""" + if obj.is_deleted: + return format_html( + '🗑️ Удалена' + ) + return format_html('✓ Активна') + get_deleted_status.short_description = 'Статус' def photo_preview(self, obj): """Превью фото в списке категорий""" @@ -49,18 +215,39 @@ class ProductCategoryAdmin(admin.ModelAdmin): class ProductTagAdmin(admin.ModelAdmin): - list_display = ('name', 'slug') + list_display = ('name', 'slug', 'get_deleted_status') + list_filter = (DeletedFilter,) prepopulated_fields = {'slug': ('name',)} search_fields = ('name',) + readonly_fields = ('deleted_at', 'deleted_by') + actions = [restore_items, delete_selected, hard_delete_selected] + + def get_queryset(self, request): + """Переопределяем queryset для доступа ко всем тегам (включая удаленные)""" + qs = ProductTag.all_objects.all() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_deleted_status(self, obj): + """Показывает статус удаления""" + if obj.is_deleted: + return format_html( + '🗑️ Удален' + ) + return format_html('✓ Активен') + get_deleted_status.short_description = 'Статус' class ProductAdmin(admin.ModelAdmin): - list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'sale_price', 'get_variant_groups_display', 'is_active') - list_filter = ('is_active', 'categories', 'tags', 'variant_groups') + list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status') + list_filter = (DeletedFilter, 'is_active', 'categories', 'tags', 'variant_groups') search_fields = ('name', 'sku', 'description', 'search_keywords') filter_horizontal = ('categories', 'tags', 'variant_groups') - readonly_fields = ('photo_preview_large',) + readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') autocomplete_fields = [] + actions = [restore_items, delete_selected, hard_delete_selected] fieldsets = ( ('Основная информация', { @@ -72,6 +259,11 @@ class ProductAdmin(admin.ModelAdmin): ('Дополнительно', { 'fields': ('tags', 'variant_groups', 'is_active') }), + ('Удаление', { + 'fields': ('deleted_at', 'deleted_by'), + 'classes': ('collapse',), + 'description': 'Информация о мягком удалении товара.' + }), ('Поиск', { 'fields': ('search_keywords',), 'classes': ('collapse',), @@ -83,6 +275,23 @@ class ProductAdmin(admin.ModelAdmin): }), ) + def get_queryset(self, request): + """Переопределяем queryset для доступа ко всем товарам (включая удаленные)""" + qs = Product.all_objects.all() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_deleted_status(self, obj): + """Показывает статус удаления""" + if obj.is_deleted: + return format_html( + '🗑️ Удален' + ) + return format_html('✓ Активен') + get_deleted_status.short_description = 'Статус' + def get_categories_display(self, obj): categories = obj.categories.all()[:3] if not categories: @@ -127,11 +336,29 @@ class ProductAdmin(admin.ModelAdmin): class ProductKitAdmin(admin.ModelAdmin): - list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'is_active') - list_filter = ('is_active', 'pricing_method', 'categories', 'tags') + list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'is_active', 'get_deleted_status') + list_filter = (DeletedFilter, 'is_active', 'pricing_method', 'categories', 'tags') prepopulated_fields = {'slug': ('name',)} filter_horizontal = ('categories', 'tags') - readonly_fields = ('photo_preview_large',) + readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') + actions = [restore_items, delete_selected, hard_delete_selected] + + def get_queryset(self, request): + """Переопределяем queryset для доступа ко всем комплектам (включая удаленные)""" + qs = ProductKit.all_objects.all() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_deleted_status(self, obj): + """Показывает статус удаления""" + if obj.is_deleted: + return format_html( + '🗑️ Удален' + ) + return format_html('✓ Активен') + get_deleted_status.short_description = 'Статус' def get_categories_display(self, obj): categories = obj.categories.all()[:3] diff --git a/myproject/products/models.py b/myproject/products/models.py index 0ece353..75fd662 100644 --- a/myproject/products/models.py +++ b/myproject/products/models.py @@ -3,9 +3,14 @@ from django.urls import reverse from django.utils.text import slugify from django.core.exceptions import ValidationError from django.db import transaction +from django.utils import timezone +from django.contrib.auth import get_user_model from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku +# Получаем User модель один раз для использования в ForeignKey +User = get_user_model() + class SKUCounter(models.Model): """ @@ -57,6 +62,60 @@ class ActiveManager(models.Manager): return super().get_queryset().filter(is_active=True) +class SoftDeleteQuerySet(models.QuerySet): + """ + QuerySet для мягкого удаления (soft delete). + Позволяет фильтровать удаленные элементы и восстанавливать их. + """ + def delete(self): + """Soft delete вместо hard delete""" + return self.update( + is_deleted=True, + deleted_at=timezone.now() + ) + + def hard_delete(self): + """Явный hard delete - удаляет из БД окончательно""" + return super().delete() + + def restore(self): + """Восстановление из удаленного состояния""" + return self.update( + is_deleted=False, + deleted_at=None, + deleted_by=None + ) + + def deleted_only(self): + """Получить только удаленные элементы""" + return self.filter(is_deleted=True) + + def not_deleted(self): + """Получить только не удаленные элементы""" + return self.filter(is_deleted=False) + + def with_deleted(self): + """Получить все элементы включая удаленные""" + return self.all() + + +class SoftDeleteManager(models.Manager): + """ + Manager для работы с мягким удалением. + По умолчанию исключает удаленные элементы из запросов. + """ + def get_queryset(self): + return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False) + + def deleted_only(self): + """Получить только удаленные элементы""" + return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True) + + def all_with_deleted(self): + """Получить все элементы включая удаленные""" + return SoftDeleteQuerySet(self.model, using=self._db).all() + + class ProductCategory(models.Model): """ Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже). @@ -67,8 +126,23 @@ class ProductCategory(models.Model): parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="Родительская категория") is_active = models.BooleanField(default=True, verbose_name="Активна") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True) + updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True) - objects = models.Manager() # Менеджер по умолчанию + # Поля для мягкого удаления + is_deleted = models.BooleanField(default=False, verbose_name="Удалена", db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") + deleted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deleted_categories', + verbose_name="Удалена пользователем" + ) + + objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) + all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) active = ActiveManager() # Кастомный менеджер для активных категорий class Meta: @@ -76,6 +150,8 @@ class ProductCategory(models.Model): verbose_name_plural = "Категории товаров" indexes = [ models.Index(fields=['is_active']), + models.Index(fields=['is_deleted']), + models.Index(fields=['is_deleted', 'created_at']), ] def __str__(self): @@ -144,6 +220,18 @@ class ProductCategory(models.Model): super().save(*args, **kwargs) + def delete(self, *args, **kwargs): + """Soft delete вместо hard delete - марк как удаленный""" + self.is_deleted = True + self.deleted_at = timezone.now() + self.save(update_fields=['is_deleted', 'deleted_at']) + # Возвращаем результат в формате Django + return 1, {self.__class__._meta.label: 1} + + def hard_delete(self): + """Полное удаление из БД (необратимо!)""" + super().delete() + class ProductTag(models.Model): """ @@ -151,10 +239,31 @@ class ProductTag(models.Model): """ name = models.CharField(max_length=100, unique=True, verbose_name="Название") slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True) + updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True) + + # Поля для мягкого удаления + is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") + deleted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deleted_tags', + verbose_name="Удален пользователем" + ) + + objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) + all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) class Meta: verbose_name = "Тег товара" verbose_name_plural = "Теги товаров" + indexes = [ + models.Index(fields=['is_deleted']), + models.Index(fields=['is_deleted', 'created_at']), + ] def __str__(self): return self.name @@ -164,6 +273,17 @@ class ProductTag(models.Model): self.slug = slugify(self.name) super().save(*args, **kwargs) + def delete(self, *args, **kwargs): + """Soft delete вместо hard delete - марк как удаленный""" + self.is_deleted = True + self.deleted_at = timezone.now() + self.save(update_fields=['is_deleted', 'deleted_at']) + return 1, {self.__class__._meta.label: 1} + + def hard_delete(self): + """Полное удаление из БД (необратимо!)""" + super().delete() + class ProductVariantGroup(models.Model): """ @@ -237,7 +357,20 @@ class Product(models.Model): help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную." ) - objects = models.Manager() # Менеджер по умолчанию + # Поля для мягкого удаления + is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") + deleted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deleted_products', + verbose_name="Удален пользователем" + ) + + objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) + all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) active = ActiveManager() # Кастомный менеджер для активных товаров class Meta: @@ -245,6 +378,8 @@ class Product(models.Model): verbose_name_plural = "Товары" indexes = [ models.Index(fields=['is_active']), + models.Index(fields=['is_deleted']), + models.Index(fields=['is_deleted', 'created_at']), ] def __str__(self): @@ -287,6 +422,18 @@ class Product(models.Model): # Используем update чтобы избежать рекурсии Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords) + def delete(self, *args, **kwargs): + """Soft delete вместо hard delete - марк как удаленный""" + self.is_deleted = True + self.deleted_at = timezone.now() + self.save(update_fields=['is_deleted', 'deleted_at']) + # Возвращаем результат в формате Django + return 1, {self.__class__._meta.label: 1} + + def hard_delete(self): + """Полное удаление из БД (необратимо!)""" + super().delete() + def get_variant_groups(self): """Возвращает все группы вариантов товара""" return self.variant_groups.all() @@ -332,7 +479,20 @@ class ProductKit(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") - objects = models.Manager() # Менеджер по умолчанию + # Поля для мягкого удаления + is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления") + deleted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deleted_kits', + verbose_name="Удален пользователем" + ) + + objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные) + all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные) active = ActiveManager() # Кастомный менеджер для активных комплектов class Meta: @@ -341,6 +501,8 @@ class ProductKit(models.Model): indexes = [ models.Index(fields=['is_active']), models.Index(fields=['slug']), + models.Index(fields=['is_deleted']), + models.Index(fields=['is_deleted', 'created_at']), ] def __str__(self): @@ -437,6 +599,18 @@ class ProductKit(models.Model): return total_sale + def delete(self, *args, **kwargs): + """Soft delete вместо hard delete - марк как удаленный""" + self.is_deleted = True + self.deleted_at = timezone.now() + self.save(update_fields=['is_deleted', 'deleted_at']) + # Возвращаем результат в формате Django + return 1, {self.__class__._meta.label: 1} + + def hard_delete(self): + """Полное удаление из БД (необратимо!)""" + super().delete() + class KitItem(models.Model): """ @@ -600,8 +774,10 @@ class ProductPhoto(models.Model): # Если было загружено новое изображение if self.image and (is_new or old_image_path): - # Обрабатываем изображение и получаем путь к оригиналу - processed_paths = ImageProcessor.process_image(self.image, 'products') + # Обрабатываем изображение с использованием SKU товара как идентификатора + # SKU гарантирует уникальность и читаемость имени файла + identifier = self.product.sku if self.product.sku else self.product.slug + processed_paths = ImageProcessor.process_image(self.image, 'products', identifier=identifier) # Сохраняем только путь к оригиналу в поле image self.image = processed_paths['original'] @@ -613,10 +789,18 @@ class ProductPhoto(models.Model): def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" + import logging from .utils.image_processor import ImageProcessor + logger = logging.getLogger(__name__) + if self.image: - ImageProcessor.delete_all_versions('products', self.image.name) + try: + logger.info(f"[ProductPhoto.delete] Удаляем изображение: {self.image.name}") + ImageProcessor.delete_all_versions('products', self.image.name) + logger.info(f"[ProductPhoto.delete] ✓ Все версии изображения удалены") + except Exception as e: + logger.error(f"[ProductPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True) super().delete(*args, **kwargs) @@ -680,8 +864,10 @@ class ProductKitPhoto(models.Model): # Если было загружено новое изображение if self.image and (is_new or old_image_path): - # Обрабатываем изображение и получаем путь к оригиналу - processed_paths = ImageProcessor.process_image(self.image, 'kits') + # Обрабатываем изображение с использованием slug комплекта как идентификатора + # slug гарантирует уникальность и читаемость имени файла + identifier = self.kit.slug + processed_paths = ImageProcessor.process_image(self.image, 'kits', identifier=identifier) # Сохраняем только путь к оригиналу в поле image self.image = processed_paths['original'] @@ -693,10 +879,18 @@ class ProductKitPhoto(models.Model): def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" + import logging from .utils.image_processor import ImageProcessor + logger = logging.getLogger(__name__) + if self.image: - ImageProcessor.delete_all_versions('kits', self.image.name) + try: + logger.info(f"[ProductKitPhoto.delete] Удаляем изображение: {self.image.name}") + ImageProcessor.delete_all_versions('kits', self.image.name) + logger.info(f"[ProductKitPhoto.delete] ✓ Все версии изображения удалены") + except Exception as e: + logger.error(f"[ProductKitPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True) super().delete(*args, **kwargs) @@ -760,8 +954,10 @@ class ProductCategoryPhoto(models.Model): # Если было загружено новое изображение if self.image and (is_new or old_image_path): - # Обрабатываем изображение и получаем путь к оригиналу - processed_paths = ImageProcessor.process_image(self.image, 'categories') + # Обрабатываем изображение с использованием slug категории как идентификатора + # slug гарантирует уникальность и читаемость имени файла + identifier = self.category.slug + processed_paths = ImageProcessor.process_image(self.image, 'categories', identifier=identifier) # Сохраняем только путь к оригиналу в поле image self.image = processed_paths['original'] @@ -773,10 +969,18 @@ class ProductCategoryPhoto(models.Model): def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" + import logging from .utils.image_processor import ImageProcessor + logger = logging.getLogger(__name__) + if self.image: - ImageProcessor.delete_all_versions('categories', self.image.name) + try: + logger.info(f"[ProductCategoryPhoto.delete] Удаляем изображение: {self.image.name}") + ImageProcessor.delete_all_versions('categories', self.image.name) + logger.info(f"[ProductCategoryPhoto.delete] ✓ Все версии изображения удалены") + except Exception as e: + logger.error(f"[ProductCategoryPhoto.delete] ✗ Ошибка при удалении версий: {str(e)}", exc_info=True) super().delete(*args, **kwargs)