Files
octopus/myproject/products/views/api_views.py
Andrey Smakotin d566819367 Feat: Add inline category creation in catalog with clickable product names
Added inline category creation functionality to catalog page with user-friendly interface:
- Inline input fields for creating root and nested categories
- '+' button in category tree header for root categories
- '+' icon on hover for each category node to create subcategories
- Clickable product/kit names in catalog grid and list views
- AJAX API endpoint for category creation with validation
- User-friendly error messages for duplicate names and constraints
- Clean implementation using clearTimeout pattern to prevent duplicate requests

Technical details:
- New API endpoint: POST /products/api/categories/create/
- Auto-generates slug and SKU for new categories
- Validates uniqueness, parent existence, and circular references
- Single-request submission with proper race condition handling
- Removes debug logging for production-ready code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 00:24:05 +03:00

1084 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
API представления для приложения products.
"""
from django.http import JsonResponse
from django.db import models
from django.core.cache import cache
from django.core.exceptions import ValidationError
import logging
from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory
logger = logging.getLogger(__name__)
def search_products_and_variants(request):
"""
API endpoint для поиска товаров, групп вариантов и комплектов (совместимость с Select2).
Используется для автокомплита при добавлении компонентов в комплект и товаров в заказ.
Параметры GET:
- q: строка поиска (term в Select2)
- id: ID товара/комплекта для получения его данных (формат: "product_123" или "kit_456")
- type: 'product', 'variant', 'kit' или 'all' (опционально, по умолчанию 'all')
- page: номер страницы для пагинации (по умолчанию 1)
Возвращает JSON в формате Select2 с группировкой:
{
"results": [
{
"text": "Товары",
"children": [
{
"id": "product_1",
"text": "Роза красная Freedom 50см (PROD-000001)",
"sku": "PROD-000001",
"price": "150.00",
"actual_price": "135.00",
"in_stock": true,
"type": "product"
}
]
},
{
"text": "Комплекты",
"children": [
{
"id": "kit_1",
"text": "Букет 'Нежность' (KIT-000001)",
"sku": "KIT-000001",
"price": "2500.00",
"actual_price": "2500.00",
"type": "kit"
}
]
}
],
"pagination": {
"more": false
}
}
"""
# Если передан ID товара/комплекта - получаем его данные напрямую
item_id = request.GET.get('id', '').strip()
if item_id:
try:
# Проверяем формат ID: "product_123" или "kit_456" или просто "123"
if '_' in item_id:
item_type, numeric_id = item_id.split('_', 1)
numeric_id = int(numeric_id)
else:
# Для обратной совместимости: если нет префикса, считаем что это product
item_type = 'product'
numeric_id = int(item_id)
if item_type == 'product':
product = Product.objects.get(id=numeric_id, is_active=True)
return JsonResponse({
'results': [{
'id': f'product_{product.id}',
'text': f"{product.name} ({product.sku})" if product.sku else product.name,
'sku': product.sku,
'price': str(product.price) if product.price else None,
'actual_price': str(product.actual_price) if product.actual_price else '0',
'in_stock': product.in_stock,
'type': 'product'
}],
'pagination': {'more': False}
})
elif item_type == 'kit':
# Для комплектов: временные комплекты можно получать по ID (для заказов)
# но не показываем их в общем поиске
kit = ProductKit.objects.get(id=numeric_id, is_active=True)
return JsonResponse({
'results': [{
'id': f'kit_{kit.id}',
'text': f"{kit.name} ({kit.sku})" if kit.sku else kit.name,
'sku': kit.sku,
'price': str(kit.price) if kit.price else None,
'actual_price': str(kit.actual_price) if kit.actual_price else '0',
'type': 'kit',
'is_temporary': kit.is_temporary
}],
'pagination': {'more': False}
})
elif item_type == 'variant':
# Для групп вариантов: получаем по ID с prefetch приоритетов
variant = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=numeric_id)
variant_price = variant.price or 0
count = variant.items.count()
return JsonResponse({
'results': [{
'id': variant.id,
'text': f"{variant.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}],
'pagination': {'more': False}
})
except (Product.DoesNotExist, ProductKit.DoesNotExist, ProductVariantGroup.DoesNotExist, ValueError):
return JsonResponse({'results': [], 'pagination': {'more': False}})
query = request.GET.get('q', '').strip()
search_type = request.GET.get('type', 'all')
page = int(request.GET.get('page', 1))
page_size = 30
results = []
# Если поиска нет - показываем популярные товары и комплекты
if not query or len(query) < 2:
# Кэшируем популярные товары на 1 час
cache_key = f'popular_items_{search_type}'
cached_results = cache.get(cache_key)
if cached_results:
return JsonResponse(cached_results)
product_results = []
kit_results = []
if search_type in ['all', 'product']:
# Показываем последние добавленные активные товары
products = Product.objects.filter(status='active')\
.order_by('-created_at')[:page_size]\
.values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
# Получаем actual_price: приоритет sale_price > price
actual_price = product['sale_price'] if product['sale_price'] else product['price']
product_results.append({
'id': f"product_{product['id']}",
'text': text,
'sku': product['sku'],
'price': str(product['price']) if product['price'] else None,
'actual_price': str(actual_price) if actual_price else '0',
'in_stock': product['in_stock'],
'type': 'product'
})
if search_type in ['all', 'kit']:
# Показываем последние добавленные активные комплекты (только постоянные)
kits = ProductKit.objects.filter(is_active=True, is_temporary=False)\
.order_by('-created_at')[:page_size]\
.values('id', 'name', 'sku', 'price', 'sale_price')
for kit in kits:
text = kit['name']
if kit['sku']:
text += f" ({kit['sku']})"
# Получаем actual_price: приоритет sale_price > price
actual_price = kit['sale_price'] if kit['sale_price'] else kit['price']
kit_results.append({
'id': f"kit_{kit['id']}",
'text': text,
'sku': kit['sku'],
'price': str(kit['price']) if kit['price'] else None,
'actual_price': str(actual_price) if actual_price else '0',
'type': 'kit'
})
# Формируем результат с группировкой или без
if search_type == 'all' and (product_results or kit_results):
# С группировкой
grouped_results = []
if product_results:
grouped_results.append({
'text': 'Товары',
'children': product_results
})
if kit_results:
grouped_results.append({
'text': 'Комплекты',
'children': kit_results
})
results = grouped_results
else:
# Без группировки (когда ищем только product или только kit)
results = product_results + kit_results
response_data = {
'results': results,
'pagination': {'more': False}
}
cache.set(cache_key, response_data, 3600)
return JsonResponse(response_data)
# Поиск товаров и комплектов (регистронезависимый поиск с приоритетом точных совпадений)
product_results = []
kit_results = []
has_more = False
# Нормализуем запрос - убираем лишние пробелы
query_normalized = ' '.join(query.split())
from django.db.models import Case, When, IntegerField
from django.conf import settings
# Поиск товаров
if search_type in ['all', 'product']:
# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL
# SQLite не поддерживает регистронезависимый поиск для кириллицы в LIKE
if 'sqlite' in settings.DATABASES['default']['ENGINE']:
from django.db.models.functions import Lower
query_lower = query_normalized.lower()
products_query = Product.objects.annotate(
name_lower=Lower('name'),
sku_lower=Lower('sku'),
description_lower=Lower('description')
).filter(
models.Q(name_lower__contains=query_lower) |
models.Q(sku_lower__contains=query_lower) |
models.Q(description_lower__contains=query_lower),
is_active=True
).annotate(
relevance=Case(
When(name_lower=query_lower, then=3),
When(name_lower__startswith=query_lower, then=2),
default=1,
output_field=IntegerField()
)
).order_by('-relevance', 'name')
else:
# Основное решение для PostgreSQL (работает корректно с кириллицей)
products_query = Product.objects.filter(
models.Q(name__icontains=query_normalized) |
models.Q(sku__icontains=query_normalized) |
models.Q(description__icontains=query_normalized),
is_active=True
).annotate(
relevance=Case(
When(name__iexact=query_normalized, then=3),
When(name__istartswith=query_normalized, then=2),
default=1,
output_field=IntegerField()
)
).order_by('-relevance', 'name')
total_products = products_query.count()
start = (page - 1) * page_size
end = start + page_size
products = products_query[start:end].values('id', 'name', 'sku', 'price', 'sale_price', 'in_stock')
for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
# Получаем actual_price: приоритет sale_price > price
actual_price = product['sale_price'] if product['sale_price'] else product['price']
product_results.append({
'id': f"product_{product['id']}",
'text': text,
'sku': product['sku'],
'price': str(product['price']) if product['price'] else None,
'actual_price': str(actual_price) if actual_price else '0',
'in_stock': product['in_stock'],
'type': 'product'
})
has_more = total_products > end
# Поиск комплектов
if search_type in ['all', 'kit']:
# Используем аналогичную логику для комплектов (только постоянные)
if 'sqlite' in settings.DATABASES['default']['ENGINE']:
from django.db.models.functions import Lower
query_lower = query_normalized.lower()
kits_query = ProductKit.objects.annotate(
name_lower=Lower('name'),
sku_lower=Lower('sku'),
description_lower=Lower('description')
).filter(
models.Q(name_lower__contains=query_lower) |
models.Q(sku_lower__contains=query_lower) |
models.Q(description_lower__contains=query_lower),
is_active=True,
is_temporary=False
).annotate(
relevance=Case(
When(name_lower=query_lower, then=3),
When(name_lower__startswith=query_lower, then=2),
default=1,
output_field=IntegerField()
)
).order_by('-relevance', 'name')
else:
kits_query = ProductKit.objects.filter(
models.Q(name__icontains=query_normalized) |
models.Q(sku__icontains=query_normalized) |
models.Q(description__icontains=query_normalized),
is_active=True,
is_temporary=False
).annotate(
relevance=Case(
When(name__iexact=query_normalized, then=3),
When(name__istartswith=query_normalized, then=2),
default=1,
output_field=IntegerField()
)
).order_by('-relevance', 'name')
kits = kits_query[:page_size].values('id', 'name', 'sku', 'price', 'sale_price')
for kit in kits:
text = kit['name']
if kit['sku']:
text += f" ({kit['sku']})"
# Получаем actual_price: приоритет sale_price > price
actual_price = kit['sale_price'] if kit['sale_price'] else kit['price']
kit_results.append({
'id': f"kit_{kit['id']}",
'text': text,
'sku': kit['sku'],
'price': str(kit['price']) if kit['price'] else None,
'actual_price': str(actual_price) if actual_price else '0',
'type': 'kit'
})
# Поиск групп вариантов
variant_results = []
if search_type in ['all', 'variant']:
variants = ProductVariantGroup.objects.filter(
models.Q(name__icontains=query) |
models.Q(description__icontains=query)
).prefetch_related('items__product')[:page_size]
for variant in variants:
count = variant.items.count()
variant_price = variant.price or 0
variant_results.append({
'id': variant.id,
'text': f"{variant.name} ({count} вариантов)",
'type': 'variant',
'count': count,
'price': str(variant_price),
'actual_price': str(variant_price)
})
# Формируем финальный результат с группировкой или без
# Для 'all' показываем только товары и комплекты (без вариантов)
if search_type == 'all':
if product_results or kit_results:
# С группировкой для заказов (товары + комплекты)
grouped_results = []
if product_results:
grouped_results.append({
'text': 'Товары',
'children': product_results
})
if kit_results:
grouped_results.append({
'text': 'Комплекты',
'children': kit_results
})
final_results = grouped_results
else:
final_results = []
elif search_type == 'variant':
# Только варианты
final_results = variant_results
else:
# Без группировки для специфичного поиска (product или kit)
final_results = product_results + kit_results + variant_results
return JsonResponse({
'results': final_results,
'pagination': {'more': has_more}
})
def validate_kit_cost(request):
"""
AJAX endpoint для валидации себестоимости комплекта в реальном времени.
Принимает список компонентов и возвращает информацию о валидности себестоимости,
доступных методах ценообразования и проблемах.
Request (JSON POST):
{
'components': [
{
'product_id': int or null,
'variant_group_id': int or null,
'quantity': float
},
...
]
}
Response (JSON):
{
'is_valid': bool,
'total_cost': float or null,
'problems': [
{
'component_name': str,
'reason': str
},
...
],
'available_methods': {
'manual': bool,
'from_sale_prices': bool,
'from_cost_plus_percent': bool,
'from_cost_plus_amount': bool
}
}
"""
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
try:
import json
from decimal import Decimal
data = json.loads(request.body)
components = data.get('components', [])
if not components:
return JsonResponse({
'is_valid': False,
'total_cost': None,
'problems': [{
'component_name': 'Комплект',
'reason': 'Комплект не содержит компонентов'
}],
'available_methods': {
'manual': True,
'from_sale_prices': True,
'from_cost_plus_percent': False,
'from_cost_plus_amount': False
}
})
# Валидируем каждый компонент
total_cost = Decimal('0.00')
problems = []
for idx, component in enumerate(components):
product_id = component.get('product_id')
variant_group_id = component.get('variant_group_id')
quantity = Decimal(str(component.get('quantity', 1)))
product = None
product_name = ''
# Получаем товар
if product_id:
try:
product = Product.objects.get(id=product_id)
product_name = product.name
except Product.DoesNotExist:
problems.append({
'component_name': f'Товар #{product_id}',
'reason': 'Товар не найден'
})
continue
elif variant_group_id:
try:
variant_group = ProductVariantGroup.objects.get(id=variant_group_id)
product = variant_group.products.filter(is_active=True).first()
if variant_group:
product_name = f"[Варианты] {variant_group.name}"
except ProductVariantGroup.DoesNotExist:
problems.append({
'component_name': f'Группа вариантов #{variant_group_id}',
'reason': 'Группа не найдена'
})
continue
if not product:
problems.append({
'component_name': product_name or f'Компонент {idx + 1}',
'reason': 'Товар не выбран или группа пуста'
})
continue
# Проверяем себестоимость
if product.cost_price is None:
problems.append({
'component_name': product_name,
'reason': 'Себестоимость не определена'
})
continue
if product.cost_price <= 0:
problems.append({
'component_name': product_name,
'reason': 'Себестоимость равна 0'
})
continue
# Добавляем в сумму
if quantity > 0:
total_cost += product.cost_price * quantity
# Определяем, какие методы доступны
is_cost_valid = len(problems) == 0
available_methods = {
'manual': True,
'from_sale_prices': True,
'from_cost_plus_percent': is_cost_valid,
'from_cost_plus_amount': is_cost_valid
}
return JsonResponse({
'is_valid': is_cost_valid,
'total_cost': float(total_cost) if is_cost_valid else None,
'problems': problems,
'available_methods': available_methods
})
except json.JSONDecodeError:
return JsonResponse({
'error': 'Invalid JSON'
}, status=400)
except Exception as e:
return JsonResponse({
'error': str(e)
}, status=500)
def create_temporary_kit_api(request):
"""
AJAX endpoint для создания временного комплекта.
Используется при оформлении заказа для создания букета "на лету".
Принимает JSON:
{
"name": "Букет для Анны",
"description": "Красные розы и белые лилии",
"order_id": 123, // опционально, если заказ уже создан
"components": [
{"product_id": 1, "quantity": "5"},
{"product_id": 2, "quantity": "3"}
]
}
Возвращает JSON:
{
"success": true,
"kit_id": 456,
"kit_name": "Букет для Анны",
"kit_sku": "KIT-000456",
"kit_price": "1500.00",
"message": "Временный комплект создан успешно"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
import json
from ..services.kit_service import create_temporary_kit
from orders.models import Order
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
description = data.get('description', '').strip()
order_id = data.get('order_id')
components = data.get('components', [])
# Получаем заказ если указан
order = None
if order_id:
try:
order = Order.objects.get(pk=order_id)
except Order.DoesNotExist:
return JsonResponse({
'success': False,
'error': f'Заказ #{order_id} не найден'
}, status=404)
# Создаем временный комплект через сервис
kit = create_temporary_kit(
name=name,
description=description,
components=components,
order=order
)
return JsonResponse({
'success': True,
'kit_id': kit.id,
'kit_name': kit.name,
'kit_sku': kit.sku,
'kit_price': str(kit.actual_price),
'message': f'Временный комплект "{kit.name}" создан успешно'
})
except ValueError as e:
logger.warning(f'Validation error при создании временного комплекта: {str(e)}')
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
except json.JSONDecodeError as e:
logger.error(f'JSON decode error при создании временного комплекта: {str(e)}')
return JsonResponse({
'success': False,
'error': 'Некорректный JSON в запросе'
}, status=400)
except ValidationError as e:
logger.error(f'Django ValidationError при создании временного комплекта: {str(e)}', exc_info=True)
return JsonResponse({
'success': False,
'error': f'Ошибка валидации: {str(e)}'
}, status=400)
except Exception as e:
# Детальное логирование для диагностики 500 ошибок
try:
data = json.loads(request.body)
name = data.get('name', 'N/A')
order_id = data.get('order_id', 'N/A')
components_count = len(data.get('components', []))
except:
name = 'N/A'
order_id = 'N/A'
components_count = 'N/A'
logger.error(
f'Непредвиденная ошибка при создании временного комплекта:\n'
f' Название: {name}\n'
f' Заказ ID: {order_id}\n'
f' Количество компонентов: {components_count}\n'
f' Пользователь: {request.user.username if request.user.is_authenticated else "Anonymous"}\n'
f' Ошибка: {str(e)}',
exc_info=True
)
# Проверяем на типичные ошибки и даём понятные сообщения
error_msg = str(e).lower()
if 'недостаточно' in error_msg or 'insufficient' in error_msg or 'stock' in error_msg:
return JsonResponse({
'success': False,
'error': f'Недостаточно товара на складе. {str(e)}'
}, status=400)
elif 'integrity' in error_msg or 'constraint' in error_msg:
return JsonResponse({
'success': False,
'error': 'Ошибка целостности данных. Проверьте, что все товары существуют.'
}, status=400)
elif 'not found' in error_msg or 'does not exist' in error_msg or 'не найден' in error_msg:
return JsonResponse({
'success': False,
'error': f'Объект не найден: {str(e)}'
}, status=404)
else:
return JsonResponse({
'success': False,
'error': f'Не удалось создать комплект: {str(e)}. Проверьте консоль сервера для деталей.'
}, status=500)
def create_tag_api(request):
"""
AJAX endpoint для быстрого создания тега из списка тегов.
Принимает JSON:
{
"name": "Новый тег"
}
Возвращает JSON:
{
"success": true,
"tag": {
"id": 1,
"name": "Новый тег",
"slug": "novyj-teg",
"is_active": true,
"products_count": 0,
"kits_count": 0
}
}
Или при ошибке:
{
"success": false,
"error": "Описание ошибки"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
try:
import json
from django.db import IntegrityError
from ..models import ProductTag
data = json.loads(request.body)
name = data.get('name', '').strip()
# Валидация
if not name:
return JsonResponse({
'success': False,
'error': 'Название тега не может быть пустым'
}, status=400)
if len(name) > 100:
return JsonResponse({
'success': False,
'error': 'Название тега слишком длинное (максимум 100 символов)'
}, status=400)
# Проверка уникальности (регистронезависимо)
# Примечание: это проверка перед созданием, но 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,
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
}
})
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({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при создании тега: {str(e)}'
}, status=500)
def toggle_tag_status_api(request, pk):
"""
AJAX endpoint для переключения статуса активности тега.
Принимает POST запрос и переключает is_active на противоположное значение.
Возвращает JSON:
{
"success": true,
"is_active": true/false,
"message": "Тег активирован" / "Тег деактивирован"
}
Или при ошибке:
{
"success": false,
"error": "Описание ошибки"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
try:
from ..models import ProductTag
# Получаем тег
tag = ProductTag.objects.get(pk=pk)
# Переключаем статус
tag.is_active = not tag.is_active
tag.save()
# Определяем сообщение
message = "Тег активирован" if tag.is_active else "Тег деактивирован"
return JsonResponse({
'success': True,
'is_active': tag.is_active,
'message': message
})
except ProductTag.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Тег не найден'
}, status=404)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при обновлении тега: {str(e)}'
}, status=500)
def rename_category_api(request, pk):
"""
AJAX endpoint для переименования категории (inline editing).
Принимает JSON:
{
"name": "Новое название"
}
Возвращает JSON:
{
"success": true,
"name": "Новое название"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
try:
import json
data = json.loads(request.body)
name = data.get('name', '').strip()
# Валидация
if not name:
return JsonResponse({
'success': False,
'error': 'Название категории не может быть пустым'
}, status=400)
if len(name) > 255:
return JsonResponse({
'success': False,
'error': 'Название слишком длинное (максимум 255 символов)'
}, status=400)
# Получаем категорию
category = ProductCategory.objects.get(pk=pk)
# Обновляем название
category.name = name
category.save(update_fields=['name'])
return JsonResponse({
'success': True,
'name': category.name
})
except ProductCategory.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Категория не найдена'
}, status=404)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при переименовании: {str(e)}'
}, status=500)
def create_category_api(request):
"""
AJAX endpoint для создания новой категории (inline creation).
Принимает JSON:
{
"name": "Название категории",
"parent_id": 123 # опционально, null для корневой категории
}
Возвращает JSON:
{
"success": true,
"category": {
"id": 456,
"name": "Название категории",
"slug": "nazvanie-kategorii",
"parent_id": 123 или null
}
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
try:
import json
from django.db import IntegrityError
data = json.loads(request.body)
name = data.get('name', '').strip()
parent_id = data.get('parent_id')
# Валидация названия
if not name:
return JsonResponse({
'success': False,
'error': 'Название категории не может быть пустым'
}, status=400)
if len(name) > 200:
return JsonResponse({
'success': False,
'error': 'Название слишком длинное (максимум 200 символов)'
}, status=400)
# Получаем родительскую категорию, если указана
parent = None
if parent_id:
try:
parent = ProductCategory.objects.get(pk=parent_id)
if not parent.is_active:
return JsonResponse({
'success': False,
'error': 'Родительская категория неактивна'
}, status=400)
except ProductCategory.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Родительская категория не найдена'
}, status=404)
# Создаем новую категорию
category = ProductCategory(
name=name,
parent=parent,
is_active=True
)
# save() автоматически сгенерирует slug и sku
category.save()
return JsonResponse({
'success': True,
'category': {
'id': category.pk,
'name': category.name,
'slug': category.slug,
'parent_id': category.parent_id
}
})
except IntegrityError as e:
# Определяем тип ошибки уникальности
error_str = str(e).lower()
if 'unique_active_category_name' in error_str or 'unique constraint' in error_str:
error_message = 'Категория с таким названием уже существует'
elif 'sku' in error_str:
error_message = 'Ошибка при генерации артикула. Попробуйте ещё раз'
else:
error_message = 'Ошибка: категория с такими данными уже существует'
return JsonResponse({
'success': False,
'error': error_message
}, status=400)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except ValidationError as e:
# ValidationError может содержать словарь с ошибками
if hasattr(e, 'message_dict'):
# Извлекаем первое сообщение из словаря
error_messages = []
for field, messages in e.message_dict.items():
if isinstance(messages, list):
error_messages.extend(messages)
else:
error_messages.append(str(messages))
error_text = ' '.join(error_messages)
elif hasattr(e, 'messages'):
error_text = ' '.join(e.messages)
else:
error_text = str(e)
# Заменяем технические сообщения на понятные
if 'unique_active_category_name' in error_text.lower():
error_text = 'Категория с таким названием уже существует'
elif 'циклическая ссылка' in error_text.lower():
error_text = 'Невозможно создать категорию: обнаружена циклическая ссылка'
elif 'слишком глубокая вложенность' in error_text.lower():
error_text = 'Превышена максимальная глубина вложенности категорий'
return JsonResponse({
'success': False,
'error': error_text
}, status=400)
except Exception as e:
logger.error(f'Ошибка при создании категории: {str(e)}')
return JsonResponse({
'success': False,
'error': f'Ошибка при создании категории: {str(e)}'
}, status=500)