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['unit'].widget.attrs.update({'class': 'form-control'})
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) 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): class ProductKitForm(forms.ModelForm):
""" """
@@ -140,11 +164,29 @@ class ProductKitForm(forms.ModelForm):
""" """
Валидация формы комплекта. Валидация формы комплекта.
Проверяет: Проверяет:
1. Что если выбран тип корректировки, указано значение 1. Уникальность имени для активных комплектов
2. Что заполнено максимум одно поле корректировки (увеличение или уменьшение) 2. Что если выбран тип корректировки, указано значение
""" """
cleaned_data = super().clean() 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_type = cleaned_data.get('price_adjustment_type')
adjustment_value = cleaned_data.get('price_adjustment_value') adjustment_value = cleaned_data.get('price_adjustment_value')
@@ -335,6 +377,29 @@ class ProductCategoryForm(forms.ModelForm):
is_active=True is_active=True
).exclude(pk__in=exclude_ids) ).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): def clean_slug(self):
"""Преобразуем пустую строку в None для автогенерации slug""" """Преобразуем пустую строку в None для автогенерации slug"""
slug = self.cleaned_data.get('slug') slug = self.cleaned_data.get('slug')
@@ -482,6 +547,30 @@ class ProductTagForm(forms.ModelForm):
self.fields['slug'].required = False self.fields['slug'].required = False
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) 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): def clean_slug(self):
"""Разрешаем пустой slug - он сгенерируется в модели""" """Разрешаем пустой slug - он сгенерируется в модели"""
slug = self.cleaned_data.get('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 import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.auth import get_user_model 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']),
models.Index(fields=['is_deleted', 'created_at']), 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): def __str__(self):
return self.name 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-идентификатор") slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
is_active = models.BooleanField(default=True, verbose_name="Активен", db_index=True) is_active = models.BooleanField(default=True, verbose_name="Активен", db_index=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True) created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
@@ -142,6 +151,14 @@ class ProductTag(models.Model):
indexes = [ indexes = [
models.Index(fields=['is_active']), models.Index(fields=['is_active']),
] ]
constraints = [
# Уникальное имя для активных тегов (неактивные могут быть переиспользованы)
models.UniqueConstraint(
fields=['name'],
condition=Q(is_active=True),
name='unique_active_tag_name'
),
]
def __str__(self): def __str__(self):
return self.name return self.name

View File

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

View File

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

View File

@@ -681,6 +681,7 @@ def create_tag_api(request):
try: try:
import json import json
from django.db import IntegrityError
from ..models import ProductTag from ..models import ProductTag
data = json.loads(request.body) data = json.loads(request.body)
@@ -700,29 +701,59 @@ def create_tag_api(request):
}, status=400) }, 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({ return JsonResponse({
'success': False, 'success': False,
'error': f'Тег "{name}" уже существует' 'error': f'Тег "{name}" уже существует'
}, status=400) }, status=400)
# Создание тега (slug будет сгенерирован автоматически в модели) try:
tag = ProductTag.objects.create( # Создание тега (slug будет сгенерирован автоматически в модели)
name=name, tag = ProductTag.objects.create(
is_active=True name=name,
) is_active=True
)
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'tag': { 'tag': {
'id': tag.id, 'id': tag.id,
'name': tag.name, 'name': tag.name,
'slug': tag.slug, 'slug': tag.slug,
'is_active': tag.is_active, 'is_active': tag.is_active,
'products_count': 0, 'products_count': 0,
'kits_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: except json.JSONDecodeError:
return JsonResponse({ 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.urls import reverse_lazy, reverse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.db.models import Q from django.db.models import Q
from django.db import IntegrityError
from ..models import ProductCategory, ProductCategoryPhoto from ..models import ProductCategory, ProductCategoryPhoto
from ..forms import ProductCategoryForm from ..forms import ProductCategoryForm
@@ -174,17 +175,46 @@ class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('products:category-list') success_url = reverse_lazy('products:category-list')
def form_valid(self, form): def form_valid(self, form):
# Сохраняем категорию try:
self.object = form.save() # Сохраняем категорию
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.') self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
# Обработка загрузки фотографий # Обработка загрузки фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category') errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors: if errors:
for error in errors: for error in errors:
messages.warning(self.request, error) 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): def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.') messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
@@ -223,17 +253,46 @@ class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
return context return context
def form_valid(self, form): def form_valid(self, form):
# Сохраняем категорию try:
self.object = form.save() # Сохраняем категорию
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.') self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
# Обработка загрузки новых фотографий # Обработка загрузки новых фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category') errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors: if errors:
for error in errors: for error in errors:
messages.warning(self.request, error) 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): def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.') 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.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.shortcuts import redirect 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 ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
@@ -210,6 +210,27 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
) )
return redirect('products:productkit-list') 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: except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}') messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback import traceback
@@ -391,6 +412,27 @@ class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateVi
return redirect('products:productkit-update', pk=self.object.pk) return redirect('products:productkit-update', pk=self.object.pk)
else: else:
return redirect('products:productkit-list') 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: except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}') messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback 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.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db.models import Q, Count from django.db.models import Q, Count
from django.db import IntegrityError
from ..models import ProductTag from ..models import ProductTag
from ..forms import ProductTagForm from ..forms import ProductTagForm
@@ -85,9 +86,32 @@ class ProductTagCreateView(LoginRequiredMixin, CreateView):
success_url = reverse_lazy('products:tag-list') success_url = reverse_lazy('products:tag-list')
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) try:
messages.success(self.request, f'Тег "{self.object.name}" успешно создан.') response = super().form_valid(form)
return response 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): class ProductTagUpdateView(LoginRequiredMixin, UpdateView):
@@ -98,9 +122,32 @@ class ProductTagUpdateView(LoginRequiredMixin, UpdateView):
success_url = reverse_lazy('products:tag-list') success_url = reverse_lazy('products:tag-list')
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) try:
messages.success(self.request, f'Тег "{self.object.name}" успешно обновлен.') response = super().form_valid(form)
return response 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): class ProductTagDeleteView(LoginRequiredMixin, DeleteView):