feat: Добавить трёхуровневую защиту от дубликатов имён товаров, категорий, тегов и комплектов

Реализована полная система обеспечения уникальности названий:

1. **Уровень БД (Model Constraints)** - добавлены UniqueConstraint для:
   - Product: уникальность имени среди активных товаров
   - ProductCategory: уникальность имени среди активных категорий
   - ProductTag: уникальность имени только для активных тегов (неактивные могут повторяться)
   - ProductKit: уникальность имени среди активных, непроизвременных комплектов

2. **Уровень формы (Form Validation)** - добавлены clean() методы для:
   - ProductForm, ProductKitForm, ProductCategoryForm, ProductTagForm
   - Валидация до попытки сохранения в БД
   - Сохранение введённых данных при ошибке валидации

3. **Уровень представления (IntegrityError Handling)** - добавлена обработка в views:
   - ProductCategoryCreateView, ProductCategoryUpdateView
   - ProductTagCreateView, ProductTagUpdateView
   - ProductKitCreateView, ProductKitUpdateView
   - create_tag_api: защита от race conditions с fallback поиском

Три уровня защиты гарантируют:
- Профилактика ошибок на уровне формы
- Обработка исключительных ситуаций в views
- Защита БД от одновременных запросов (race conditions)
- Пользователь видит понятное сообщение об ошибке вместо 500 ошибки

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-15 13:49:52 +03:00
parent 0b41c6815c
commit 079bd23829
9 changed files with 386 additions and 45 deletions

View File

@@ -681,6 +681,7 @@ def create_tag_api(request):
try:
import json
from django.db import IntegrityError
from ..models import ProductTag
data = json.loads(request.body)
@@ -700,29 +701,59 @@ def create_tag_api(request):
}, status=400)
# Проверка уникальности (регистронезависимо)
if ProductTag.objects.filter(name__iexact=name).exists():
# Примечание: это проверка перед созданием, но race condition все еще возможна
if ProductTag.objects.filter(name__iexact=name, is_active=True).exists():
return JsonResponse({
'success': False,
'error': f'Тег "{name}" уже существует'
}, status=400)
# Создание тега (slug будет сгенерирован автоматически в модели)
tag = ProductTag.objects.create(
name=name,
is_active=True
)
try:
# Создание тега (slug будет сгенерирован автоматически в модели)
tag = ProductTag.objects.create(
name=name,
is_active=True
)
return JsonResponse({
'success': True,
'tag': {
'id': tag.id,
'name': tag.name,
'slug': tag.slug,
'is_active': tag.is_active,
'products_count': 0,
'kits_count': 0
}
})
return JsonResponse({
'success': True,
'tag': {
'id': tag.id,
'name': tag.name,
'slug': tag.slug,
'is_active': tag.is_active,
'products_count': 0,
'kits_count': 0
}
})
except IntegrityError as e:
# Защита от race condition: если 2 запроса одновременно попытались создать тег
error_msg = str(e).lower()
if 'unique_active_tag_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
# Тег был создан параллельным запросом, получаем его
tag = ProductTag.objects.get(name__iexact=name, is_active=True)
return JsonResponse({
'success': True,
'tag': {
'id': tag.id,
'name': tag.name,
'slug': tag.slug,
'is_active': tag.is_active,
'products_count': tag.products.count(),
'kits_count': tag.kits.count()
}
})
elif 'slug' in error_msg:
# Конфликт slug, это редко должно происходить но обработаем
return JsonResponse({
'success': False,
'error': f'Тег с названием "{name}" не может быть создан (конфликт идентификатора). Пожалуйста, попробуйте другое название.'
}, status=400)
else:
return JsonResponse({
'success': False,
'error': 'Ошибка при создании тега: нарушение уникальности'
}, status=500)
except json.JSONDecodeError:
return JsonResponse({

View File

@@ -8,6 +8,7 @@ from django.views.generic import ListView, CreateView, DetailView, UpdateView, D
from django.urls import reverse_lazy, reverse
from django.shortcuts import redirect
from django.db.models import Q
from django.db import IntegrityError
from ..models import ProductCategory, ProductCategoryPhoto
from ..forms import ProductCategoryForm
@@ -174,17 +175,46 @@ class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('products:category-list')
def form_valid(self, form):
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
try:
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
# Обработка загрузки фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors:
for error in errors:
messages.warning(self.request, error)
# Обработка загрузки фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors:
for error in errors:
messages.warning(self.request, error)
return redirect(self.get_success_url())
return redirect(self.get_success_url())
except IntegrityError as e:
# Обработка нарушения уникальности
error_msg = str(e).lower()
if 'unique_active_category_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
messages.error(
self.request,
f'Ошибка: категория с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название.'
)
elif 'slug' in error_msg or 'unique' in error_msg:
messages.error(
self.request,
'Ошибка: категория с таким названием уже существует. '
'Пожалуйста, используйте другое название.'
)
elif 'sku' in error_msg:
messages.error(
self.request,
f'Ошибка: категория с артикулом "{form.cleaned_data.get("sku", "")}" уже существует. '
'Пожалуйста, используйте другой артикул.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении категории. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
@@ -223,17 +253,46 @@ class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
return context
def form_valid(self, form):
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
try:
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
# Обработка загрузки новых фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors:
for error in errors:
messages.warning(self.request, error)
# Обработка загрузки новых фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors:
for error in errors:
messages.warning(self.request, error)
return redirect(self.get_success_url())
return redirect(self.get_success_url())
except IntegrityError as e:
# Обработка нарушения уникальности
error_msg = str(e).lower()
if 'unique_active_category_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
messages.error(
self.request,
f'Ошибка: категория с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название.'
)
elif 'slug' in error_msg or 'unique' in error_msg:
messages.error(
self.request,
'Ошибка: категория с таким названием уже существует. '
'Пожалуйста, используйте другое название.'
)
elif 'sku' in error_msg:
messages.error(
self.request,
f'Ошибка: категория с артикулом "{form.cleaned_data.get("sku", "")}" уже существует. '
'Пожалуйста, используйте другой артикул.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении категории. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.shortcuts import redirect
from django.db import transaction
from django.db import transaction, IntegrityError
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
@@ -210,6 +210,27 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
)
return redirect('products:productkit-list')
except IntegrityError as e:
# Обработка нарушения уникальности в БД
error_msg = str(e).lower()
if 'unique_active_kit_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
messages.error(
self.request,
f'Ошибка: комплект с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название.'
)
elif 'slug' in error_msg or 'unique' in error_msg:
messages.error(
self.request,
'Ошибка: комплект с таким названием уже существует. '
'Пожалуйста, используйте другое название.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении комплекта. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback
@@ -391,6 +412,27 @@ class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateVi
return redirect('products:productkit-update', pk=self.object.pk)
else:
return redirect('products:productkit-list')
except IntegrityError as e:
# Обработка нарушения уникальности в БД
error_msg = str(e).lower()
if 'unique_active_kit_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
messages.error(
self.request,
f'Ошибка: комплект с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название.'
)
elif 'slug' in error_msg or 'unique' in error_msg:
messages.error(
self.request,
'Ошибка: комплект с таким названием уже существует. '
'Пожалуйста, используйте другое название.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении комплекта. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback

View File

@@ -6,6 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.db.models import Q, Count
from django.db import IntegrityError
from ..models import ProductTag
from ..forms import ProductTagForm
@@ -85,9 +86,32 @@ class ProductTagCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('products:tag-list')
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f'Тег "{self.object.name}" успешно создан.')
return response
try:
response = super().form_valid(form)
messages.success(self.request, f'Тег "{self.object.name}" успешно создан.')
return response
except IntegrityError as e:
# Обработка нарушения уникальности
error_msg = str(e).lower()
if 'unique_active_tag_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
messages.error(
self.request,
f'Ошибка: активный тег с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название или переименуйте существующий тег.'
)
elif 'slug' in error_msg or 'unique' in error_msg:
messages.error(
self.request,
'Ошибка: тег с таким названием уже существует. '
'Пожалуйста, используйте другое название.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении тега. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
class ProductTagUpdateView(LoginRequiredMixin, UpdateView):
@@ -98,9 +122,32 @@ class ProductTagUpdateView(LoginRequiredMixin, UpdateView):
success_url = reverse_lazy('products:tag-list')
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f'Тег "{self.object.name}" успешно обновлен.')
return response
try:
response = super().form_valid(form)
messages.success(self.request, f'Тег "{self.object.name}" успешно обновлен.')
return response
except IntegrityError as e:
# Обработка нарушения уникальности
error_msg = str(e).lower()
if 'unique_active_tag_name' in error_msg or ('name' in error_msg and 'duplicate' in error_msg):
messages.error(
self.request,
f'Ошибка: активный тег с названием "{form.instance.name}" уже существует. '
'Пожалуйста, используйте другое название или переименуйте существующий тег.'
)
elif 'slug' in error_msg or 'unique' in error_msg:
messages.error(
self.request,
'Ошибка: тег с таким названием уже существует. '
'Пожалуйста, используйте другое название.'
)
else:
messages.error(
self.request,
'Ошибка при сохранении тега. Пожалуйста, проверьте введённые данные.'
)
return self.form_invalid(form)
class ProductTagDeleteView(LoginRequiredMixin, DeleteView):