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

@@ -68,6 +68,30 @@ class ProductForm(forms.ModelForm):
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
def clean(self):
"""Валидация уникальности имени для активных товаров"""
cleaned_data = super().clean()
name = cleaned_data.get('name')
if name:
# Проверяем уникальность имени среди активных товаров
# Исключаем текущий товар при редактировании (self.instance.pk)
existing = Product.objects.filter(
name=name,
is_deleted=False
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
self.add_error('name',
f'Товар с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return cleaned_data
class ProductKitForm(forms.ModelForm):
"""
@@ -140,11 +164,29 @@ class ProductKitForm(forms.ModelForm):
"""
Валидация формы комплекта.
Проверяет:
1. Что если выбран тип корректировки, указано значение
2. Что заполнено максимум одно поле корректировки (увеличение или уменьшение)
1. Уникальность имени для активных комплектов
2. Что если выбран тип корректировки, указано значение
"""
cleaned_data = super().clean()
# Проверяем уникальность имени среди активных комплектов
name = cleaned_data.get('name')
if name:
existing = ProductKit.objects.filter(
name=name,
is_deleted=False,
is_temporary=False
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
self.add_error('name',
f'Комплект с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
adjustment_type = cleaned_data.get('price_adjustment_type')
adjustment_value = cleaned_data.get('price_adjustment_value')
@@ -335,6 +377,29 @@ class ProductCategoryForm(forms.ModelForm):
is_active=True
).exclude(pk__in=exclude_ids)
def clean(self):
"""Валидация уникальности имени для активных категорий"""
cleaned_data = super().clean()
name = cleaned_data.get('name')
if name:
# Проверяем уникальность имени среди активных категорий
existing = ProductCategory.objects.filter(
name=name,
is_deleted=False
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
self.add_error('name',
f'Категория с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return cleaned_data
def clean_slug(self):
"""Преобразуем пустую строку в None для автогенерации slug"""
slug = self.cleaned_data.get('slug')
@@ -482,6 +547,30 @@ class ProductTagForm(forms.ModelForm):
self.fields['slug'].required = False
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
def clean(self):
"""Валидация уникальности имени для активных тегов"""
cleaned_data = super().clean()
name = cleaned_data.get('name')
is_active = cleaned_data.get('is_active', True)
if name and is_active:
# Проверяем уникальность имени среди активных тегов
existing = ProductTag.objects.filter(
name=name,
is_active=True
)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
self.add_error('name',
f'Тег с названием "{name}" уже существует. '
f'Пожалуйста, используйте другое название.'
)
return cleaned_data
def clean_slug(self):
"""Разрешаем пустой slug - он сгенерируется в модели"""
slug = self.cleaned_data.get('slug')

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.0.10 on 2025-11-15 10:37
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0002_initial'),
('products', '0002_photoprocessingstatus'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='producttag',
name='name',
field=models.CharField(max_length=100, verbose_name='Название'),
),
migrations.AddConstraint(
model_name='product',
constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('name',), name='unique_active_product_name'),
),
migrations.AddConstraint(
model_name='productcategory',
constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('name',), name='unique_active_category_name'),
),
migrations.AddConstraint(
model_name='productkit',
constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False), ('is_temporary', False)), fields=('name',), name='unique_active_kit_name'),
),
migrations.AddConstraint(
model_name='producttag',
constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('name',), name='unique_active_tag_name'),
),
]

View File

@@ -2,6 +2,7 @@
Модели категорий и тегов для товаров и комплектов.
"""
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.contrib.auth import get_user_model
@@ -49,6 +50,14 @@ class ProductCategory(models.Model):
models.Index(fields=['is_deleted']),
models.Index(fields=['is_deleted', 'created_at']),
]
constraints = [
# Уникальное имя для активных категорий (исключаем удалённые)
models.UniqueConstraint(
fields=['name'],
condition=Q(is_deleted=False),
name='unique_active_category_name'
),
]
def __str__(self):
return self.name
@@ -127,7 +136,7 @@ class ProductTag(models.Model):
"""
Свободные теги для фильтрации и поиска.
"""
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
name = models.CharField(max_length=100, verbose_name="Название")
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
is_active = models.BooleanField(default=True, verbose_name="Активен", db_index=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
@@ -142,6 +151,14 @@ class ProductTag(models.Model):
indexes = [
models.Index(fields=['is_active']),
]
constraints = [
# Уникальное имя для активных тегов (неактивные могут быть переиспользованы)
models.UniqueConstraint(
fields=['name'],
condition=Q(is_active=True),
name='unique_active_tag_name'
),
]
def __str__(self):
return self.name

View File

@@ -4,6 +4,7 @@
"""
from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.core.exceptions import ValidationError
@@ -110,6 +111,15 @@ class ProductKit(BaseProductEntity):
models.Index(fields=['is_temporary']),
models.Index(fields=['order']),
]
constraints = [
# Уникальное имя для активных комплектов (исключаем удалённые)
# Примечание: временные комплекты могут иметь дубли имён (создаются для заказов)
models.UniqueConstraint(
fields=['name'],
condition=Q(is_deleted=False, is_temporary=False),
name='unique_active_kit_name'
),
]
@property
def actual_price(self):

View File

@@ -2,6 +2,7 @@
Модель Product - базовый товар (цветок, упаковка, аксессуар).
"""
from django.db import models
from django.db.models import Q
from .base import BaseProductEntity
from .categories import ProductCategory, ProductTag
@@ -101,6 +102,14 @@ class Product(BaseProductEntity):
models.Index(fields=['in_stock']),
models.Index(fields=['sku']),
]
constraints = [
# Уникальное имя для активных товаров (исключаем удалённые)
models.UniqueConstraint(
fields=['name'],
condition=Q(is_deleted=False),
name='unique_active_product_name'
),
]
@property
def actual_price(self):

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,12 +701,14 @@ 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)
try:
# Создание тега (slug будет сгенерирован автоматически в модели)
tag = ProductTag.objects.create(
name=name,
@@ -723,6 +726,34 @@ def create_tag_api(request):
'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,6 +175,7 @@ class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('products:category-list')
def form_valid(self, form):
try:
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
@@ -186,6 +188,34 @@ class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
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, 'Пожалуйста, исправьте ошибки в форме.')
return super().form_invalid(form)
@@ -223,6 +253,7 @@ class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
return context
def form_valid(self, form):
try:
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
@@ -235,6 +266,34 @@ class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
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, 'Пожалуйста, исправьте ошибки в форме.')
return super().form_invalid(form)

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,10 +86,33 @@ class ProductTagCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('products:tag-list')
def form_valid(self, form):
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,10 +122,33 @@ class ProductTagUpdateView(LoginRequiredMixin, UpdateView):
success_url = reverse_lazy('products:tag-list')
def form_valid(self, form):
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):
"""Удаление тега с подтверждением"""