diff --git a/myproject/products/models/base.py b/myproject/products/models/base.py index 36d29b1..778be4a 100644 --- a/myproject/products/models/base.py +++ b/myproject/products/models/base.py @@ -165,14 +165,55 @@ class BaseProductEntity(models.Model): super().delete() def save(self, *args, **kwargs): - """Автогенерация slug из name если не задан""" - if not self.slug or self.slug.strip() == '': - # Используем централизованный сервис для генерации slug - from ..services.slug_service import SlugService - self.slug = SlugService.generate_unique_slug( - self.name, - self.__class__, - self.pk - ) + """ + Автогенерация slug из name если не задан. + Использует transaction.atomic() и retry логику для обработки race condition. + """ + from django.db import transaction, IntegrityError + from ..services.slug_service import SlugService - super().save(*args, **kwargs) + # Генерируем базовый slug + if not self.slug or self.slug.strip() == '': + transliterated_name = __import__('unidecode', fromlist=['unidecode']).unidecode(self.name) + base_slug = __import__('django.utils.text', fromlist=['slugify']).slugify(transliterated_name) + else: + base_slug = self.slug + + # Пытаемся сохранить с retry при IntegrityError + max_retries = 5 + for attempt in range(max_retries): + try: + with transaction.atomic(): + # Попытка 1: используем обычный способ генерации + if not self.slug or self.slug.strip() == '': + if attempt == 0: + # Первая попытка - используем обычный generate_unique_slug + self.slug = SlugService.generate_unique_slug( + self.name, + self.__class__, + self.pk + ) + else: + # Retry попытки - используем get_next_available_slug с суффиксом + try: + self.slug = SlugService.get_next_available_slug( + base_slug, + self.__class__, + self.pk, + start_counter=attempt + ) + except ValueError: + # Если все попытки с суффиксом исчерпаны, добавляем timestamp + import time + self.slug = f"{base_slug}-{int(time.time() % 10000)}" + + # Основное сохранение + super().save(*args, **kwargs) + return # Успешно сохранили, выходим + + except IntegrityError as e: + # Если это последняя попытка, выбрасываем исключение + if attempt == max_retries - 1: + raise + # Иначе пытаемся снова со следующим slug'ом + continue diff --git a/myproject/products/services/slug_service.py b/myproject/products/services/slug_service.py index 9dc9aa6..813f2e7 100644 --- a/myproject/products/services/slug_service.py +++ b/myproject/products/services/slug_service.py @@ -12,6 +12,27 @@ class SlugService: Используется моделями Product, ProductKit, ProductCategory, ProductTag. """ + @staticmethod + def _get_base_queryset(model_class): + """ + Получает базовый queryset для модели. + Использует all_objects если модель имеет мягкое удаление, иначе objects. + + Это важно для проверки уникальности - нужно проверять ALL объекты, + включая удаленные, иначе могут быть дубли между deleted и active товарами. + + Args: + model_class: Класс модели + + Returns: + QuerySet для проверки уникальности + """ + # Если модель использует мягкое удаление (все наследники BaseProductEntity), + # используем all_objects для проверки ALL записей + if hasattr(model_class, 'all_objects'): + return model_class.all_objects + return model_class.objects + @staticmethod def generate_unique_slug(name, model_class, instance_pk=None): """ @@ -38,10 +59,11 @@ class SlugService: # Обеспечиваем уникальность slug = base_slug counter = 1 + queryset = SlugService._get_base_queryset(model_class) while True: # Проверяем существование slug, исключая текущий экземпляр если это обновление - query = model_class.objects.filter(slug=slug) + query = queryset.filter(slug=slug) if instance_pk: query = query.exclude(pk=instance_pk) @@ -54,6 +76,42 @@ class SlugService: return slug + @staticmethod + def get_next_available_slug(base_slug, model_class, instance_pk=None, start_counter=1, max_attempts=100): + """ + Получает следующий доступный slug с числовым суффиксом. + Используется при retry обработке IntegrityError. + + Args: + base_slug (str): Базовый slug (например, "test-product") + model_class: Класс модели для проверки уникальности + instance_pk (int, optional): ID текущего экземпляра + start_counter (int): Начальное значение счетчика (по умолчанию 1) + max_attempts (int): Максимум попыток (по умолчанию 100) + + Returns: + str: Доступный slug с суффиксом (например, "test-product-1") + + Raises: + ValueError: Если не найден доступный slug после max_attempts попыток + """ + queryset = SlugService._get_base_queryset(model_class) + + for counter in range(start_counter, start_counter + max_attempts): + slug = f"{base_slug}-{counter}" + query = queryset.filter(slug=slug) + if instance_pk: + query = query.exclude(pk=instance_pk) + + if not query.exists(): + return slug + + # Если все попытки исчерпаны + raise ValueError( + f"Не удалось сгенерировать уникальный slug для '{base_slug}' " + f"после {max_attempts} попыток" + ) + @staticmethod def transliterate(text): """ diff --git a/myproject/products/views/product_views.py b/myproject/products/views/product_views.py index f301742..69fd267 100644 --- a/myproject/products/views/product_views.py +++ b/myproject/products/views/product_views.py @@ -113,20 +113,41 @@ class ProductCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView) return reverse_lazy('products:product-list') def form_valid(self, form): - response = super().form_valid(form) + from django.db import IntegrityError - # Handle photo uploads - photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product') - if photo_errors: - for error in photo_errors: - # Если это предупреждение о лимите фото - warning, иначе - error - if 'Загружено' in error and 'обработано только' in error: - messages.warning(self.request, error) - else: - messages.error(self.request, error) + try: + response = super().form_valid(form) - messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!') - return response + # Handle photo uploads + photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product') + if photo_errors: + for error in photo_errors: + # Если это предупреждение о лимите фото - warning, иначе - error + if 'Загружено' in error and 'обработано только' in error: + messages.warning(self.request, error) + else: + messages.error(self.request, error) + + messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!') + return response + + except IntegrityError as e: + # Обработка ошибки дублирования slug'а или других unique constraints + error_msg = str(e).lower() + if 'slug' in error_msg or 'duplicate key' in error_msg: + messages.error( + self.request, + f'Ошибка: товар с названием "{form.instance.name}" уже существует. ' + 'Пожалуйста, используйте другое название.' + ) + else: + messages.error( + self.request, + 'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.' + ) + + # Перенаправляем обратно на форму с сохранённой информацией + return self.form_invalid(form) class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): @@ -164,20 +185,41 @@ class ProductUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView) return context def form_valid(self, form): - response = super().form_valid(form) + from django.db import IntegrityError - # Handle photo uploads - photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product') - if photo_errors: - for error in photo_errors: - # Если это предупреждение о лимите фото - warning, иначе - error - if 'Загружено' in error and 'обработано только' in error: - messages.warning(self.request, error) - else: - messages.error(self.request, error) + try: + response = super().form_valid(form) - messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!') - return response + # Handle photo uploads + photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product') + if photo_errors: + for error in photo_errors: + # Если это предупреждение о лимите фото - warning, иначе - error + if 'Загружено' in error and 'обработано только' in error: + messages.warning(self.request, error) + else: + messages.error(self.request, error) + + messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!') + return response + + except IntegrityError as e: + # Обработка ошибки дублирования slug'а или других unique constraints + error_msg = str(e).lower() + if 'slug' in error_msg or 'duplicate key' in error_msg: + messages.error( + self.request, + f'Ошибка: товар с названием "{form.instance.name}" уже существует. ' + 'Пожалуйста, используйте другое название.' + ) + else: + messages.error( + self.request, + 'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.' + ) + + # Перенаправляем обратно на форму с сохранённой информацией + return self.form_invalid(form) class ProductDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):