Files
octopus/myproject/products/views/api_views.py
Andrey Smakotin 1daee88cbd Добавлено быстрое создание тегов на странице списка
API функционал:
- Создан endpoint /api/tags/create/ для AJAX создания тегов
- Валидация: пустое имя, длина, уникальность (регистронезависимо)
- Автоматическая генерация slug через модель
- Возврат JSON с данными созданного тега

UI функционал на странице списка тегов:
- Панель быстрого создания с крупным полем ввода
- Автофокус на поле при загрузке страницы
- Создание тега по нажатию Enter или клику на кнопку
- Индикатор загрузки (спиннер) во время создания
- Блокировка поля/кнопки во время запроса
- Автоматическая перезагрузка страницы после создания
- Возврат фокуса в поле при ошибке
- Красивые alert-сообщения об успехе/ошибке
- CSRF защита

Workflow: ввёл название → Enter → тег создан → фокус снова в поле → можно вводить следующий

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 23:26:40 +03:00

714 lines
27 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 ..models import Product, ProductVariantGroup, ProductKit
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}
})
except (Product.DoesNotExist, ProductKit.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(is_active=True)\
.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('products')[:page_size]
for variant in variants:
count = variant.products.filter(is_active=True).count()
variant_results.append({
'id': variant.id,
'text': f"{variant.name} ({count} вариантов)",
'type': 'variant',
'count': count
})
# Формируем финальный результат с группировкой или без
# Для '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:
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
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_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 ..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)
# Проверка уникальности (регистронезависимо)
if ProductTag.objects.filter(name__iexact=name).exists():
return JsonResponse({
'success': False,
'error': f'Тег "{name}" уже существует'
}, status=400)
# Создание тега (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 json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при создании тега: {str(e)}'
}, status=500)