diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 787c9bf..e06709a 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -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') diff --git a/myproject/products/migrations/0003_alter_producttag_name_and_more.py b/myproject/products/migrations/0003_alter_producttag_name_and_more.py new file mode 100644 index 0000000..7db5590 --- /dev/null +++ b/myproject/products/migrations/0003_alter_producttag_name_and_more.py @@ -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'), + ), + ] diff --git a/myproject/products/models/categories.py b/myproject/products/models/categories.py index 88ee683..520a6c2 100644 --- a/myproject/products/models/categories.py +++ b/myproject/products/models/categories.py @@ -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 diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index 733412a..3257687 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -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): diff --git a/myproject/products/models/products.py b/myproject/products/models/products.py index ec6c775..9880d53 100644 --- a/myproject/products/models/products.py +++ b/myproject/products/models/products.py @@ -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): diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 677485b..4e3baa5 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -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({ diff --git a/myproject/products/views/category_views.py b/myproject/products/views/category_views.py index 37901f8..257458a 100644 --- a/myproject/products/views/category_views.py +++ b/myproject/products/views/category_views.py @@ -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, 'Пожалуйста, исправьте ошибки в форме.') diff --git a/myproject/products/views/productkit_views.py b/myproject/products/views/productkit_views.py index eb89591..388e8cf 100644 --- a/myproject/products/views/productkit_views.py +++ b/myproject/products/views/productkit_views.py @@ -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 diff --git a/myproject/products/views/tag_views.py b/myproject/products/views/tag_views.py index f433c45..3f9d7be 100644 --- a/myproject/products/views/tag_views.py +++ b/myproject/products/views/tag_views.py @@ -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):