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:
@@ -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({
|
||||
|
||||
@@ -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, 'Пожалуйста, исправьте ошибки в форме.')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user