fix: Обработка race condition при дублировании slug товаров
Реализована трёхуровневая защита от IntegrityError при создании товаров с одинаковым названием: 1. SlugService улучшен: - Добавлен метод _get_base_queryset() для работы с мягким удалением - Проверка всех объектов включая удалённые (all_objects) - Новый метод get_next_available_slug() для retry обработки - Максимум 100 попыток поиска уникального slug 2. BaseProductEntity.save() защищена: - transaction.atomic() для атомарности операции - Retry логика с 5 попытками при IntegrityError - При конфликте добавляется суффикс (-1, -2, -3...) - Fallback на timestamp если суффиксы исчерпаны 3. Views обрабатывают IntegrityError: - ProductCreateView.form_valid() перехватывает ошибку - ProductUpdateView.form_valid() перехватывает ошибку - Пользователю показывается дружелюбное сообщение об ошибке - Нет 500 ошибок - вместо этого form_invalid() с сообщением Эффект: - До: User создаёт товар "Роза красная" 2 раза → IntegrityError → 500 ошибка - После: User создаёт товар "Роза красная" 2 раза → slug автоматически становится "roza-krasnaya-1" Протестировано на Django shell и синтаксис проверен. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -165,14 +165,55 @@ class BaseProductEntity(models.Model):
|
|||||||
super().delete()
|
super().delete()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Автогенерация slug из name если не задан"""
|
"""
|
||||||
if not self.slug or self.slug.strip() == '':
|
Автогенерация slug из name если не задан.
|
||||||
# Используем централизованный сервис для генерации slug
|
Использует transaction.atomic() и retry логику для обработки race condition.
|
||||||
from ..services.slug_service import SlugService
|
"""
|
||||||
self.slug = SlugService.generate_unique_slug(
|
from django.db import transaction, IntegrityError
|
||||||
self.name,
|
from ..services.slug_service import SlugService
|
||||||
self.__class__,
|
|
||||||
self.pk
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ class SlugService:
|
|||||||
Используется моделями Product, ProductKit, ProductCategory, ProductTag.
|
Используется моделями 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
|
@staticmethod
|
||||||
def generate_unique_slug(name, model_class, instance_pk=None):
|
def generate_unique_slug(name, model_class, instance_pk=None):
|
||||||
"""
|
"""
|
||||||
@@ -38,10 +59,11 @@ class SlugService:
|
|||||||
# Обеспечиваем уникальность
|
# Обеспечиваем уникальность
|
||||||
slug = base_slug
|
slug = base_slug
|
||||||
counter = 1
|
counter = 1
|
||||||
|
queryset = SlugService._get_base_queryset(model_class)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Проверяем существование slug, исключая текущий экземпляр если это обновление
|
# Проверяем существование slug, исключая текущий экземпляр если это обновление
|
||||||
query = model_class.objects.filter(slug=slug)
|
query = queryset.filter(slug=slug)
|
||||||
if instance_pk:
|
if instance_pk:
|
||||||
query = query.exclude(pk=instance_pk)
|
query = query.exclude(pk=instance_pk)
|
||||||
|
|
||||||
@@ -54,6 +76,42 @@ class SlugService:
|
|||||||
|
|
||||||
return slug
|
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
|
@staticmethod
|
||||||
def transliterate(text):
|
def transliterate(text):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -113,20 +113,41 @@ class ProductCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView)
|
|||||||
return reverse_lazy('products:product-list')
|
return reverse_lazy('products:product-list')
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
from django.db import IntegrityError
|
||||||
|
|
||||||
# Handle photo uploads
|
try:
|
||||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
response = super().form_valid(form)
|
||||||
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}" успешно создан!')
|
# Handle photo uploads
|
||||||
return response
|
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):
|
class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
|
||||||
@@ -164,20 +185,41 @@ class ProductUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView)
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
from django.db import IntegrityError
|
||||||
|
|
||||||
# Handle photo uploads
|
try:
|
||||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
response = super().form_valid(form)
|
||||||
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}" успешно обновлен!')
|
# Handle photo uploads
|
||||||
return response
|
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):
|
class ProductDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||||
|
|||||||
Reference in New Issue
Block a user