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

@@ -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):