Compare commits
2 Commits
036b9d1634
...
22e300394b
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e300394b | |||
| 01873be15d |
@@ -667,7 +667,16 @@ class ShowcaseItem(models.Model):
|
||||
from datetime import timedelta
|
||||
|
||||
self.status = 'in_cart'
|
||||
self.locked_by_user = user
|
||||
|
||||
# Проверяем тип пользователя - locked_by_user только для CustomUser
|
||||
from accounts.models import CustomUser
|
||||
if isinstance(user, CustomUser):
|
||||
self.locked_by_user = user
|
||||
else:
|
||||
# Для PlatformAdmin и других типов пользователей поле оставляем пустым
|
||||
# Блокировка будет работать через cart_session_id
|
||||
self.locked_by_user = None
|
||||
|
||||
self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
|
||||
self.cart_session_id = session_id
|
||||
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])
|
||||
|
||||
@@ -263,13 +263,25 @@ def pos_terminal(request):
|
||||
|
||||
if showcase_item_ids:
|
||||
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
|
||||
locked_items = ShowcaseItem.objects.filter(
|
||||
id__in=showcase_item_ids,
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
locked_by_user=request.user,
|
||||
cart_lock_expires_at__gt=timezone.now()
|
||||
)
|
||||
from accounts.models import CustomUser
|
||||
if isinstance(request.user, CustomUser):
|
||||
locked_items = ShowcaseItem.objects.filter(
|
||||
id__in=showcase_item_ids,
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
locked_by_user=request.user,
|
||||
cart_lock_expires_at__gt=timezone.now()
|
||||
)
|
||||
else:
|
||||
# Для PlatformAdmin используем проверку по сессии
|
||||
session_id = request.session.session_key or ''
|
||||
locked_items = ShowcaseItem.objects.filter(
|
||||
id__in=showcase_item_ids,
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
cart_session_id=session_id,
|
||||
cart_lock_expires_at__gt=timezone.now()
|
||||
)
|
||||
|
||||
locked_count = locked_items.count()
|
||||
|
||||
@@ -655,11 +667,22 @@ def remove_showcase_kit_from_cart(request, kit_id):
|
||||
showcase_item_ids = []
|
||||
|
||||
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
|
||||
qs = ShowcaseItem.objects.filter(
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
locked_by_user=request.user
|
||||
)
|
||||
from accounts.models import CustomUser
|
||||
|
||||
if isinstance(request.user, CustomUser):
|
||||
qs = ShowcaseItem.objects.filter(
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
locked_by_user=request.user
|
||||
)
|
||||
else:
|
||||
# Для PlatformAdmin используем проверку по сессии
|
||||
session_id = request.session.session_key or ''
|
||||
qs = ShowcaseItem.objects.filter(
|
||||
product_kit=kit,
|
||||
status='in_cart',
|
||||
cart_session_id=session_id
|
||||
)
|
||||
|
||||
# Если указаны конкретные ID - фильтруем только их
|
||||
if showcase_item_ids:
|
||||
@@ -710,10 +733,22 @@ def release_all_my_showcase_locks(request):
|
||||
|
||||
try:
|
||||
# Снимаем ВСЕ блокировки текущего пользователя
|
||||
updated_count = ShowcaseItem.objects.filter(
|
||||
status='in_cart',
|
||||
locked_by_user=request.user
|
||||
).update(
|
||||
from accounts.models import CustomUser
|
||||
|
||||
if isinstance(request.user, CustomUser):
|
||||
qs_to_release = ShowcaseItem.objects.filter(
|
||||
status='in_cart',
|
||||
locked_by_user=request.user
|
||||
)
|
||||
else:
|
||||
# Для PlatformAdmin фильтруем по сессии
|
||||
session_id = request.session.session_key or ''
|
||||
qs_to_release = ShowcaseItem.objects.filter(
|
||||
status='in_cart',
|
||||
cart_session_id=session_id
|
||||
)
|
||||
|
||||
updated_count = qs_to_release.update(
|
||||
status='available',
|
||||
locked_by_user=None,
|
||||
cart_lock_expires_at=None,
|
||||
|
||||
@@ -13,6 +13,7 @@ from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
|
||||
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
|
||||
from .models import UnitOfMeasure, ProductSalesUnit
|
||||
from .models import BouquetName
|
||||
from .admin_displays import (
|
||||
format_quality_badge,
|
||||
format_quality_display,
|
||||
@@ -1086,3 +1087,42 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
count
|
||||
)
|
||||
get_options_count.short_description = 'Вариантов'
|
||||
|
||||
|
||||
@admin.register(BouquetName)
|
||||
class BouquetNameAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
"""
|
||||
Административный интерфейс для управления названиями букетов
|
||||
"""
|
||||
list_display = ('name', 'language', 'is_approved', 'usage_count', 'generated_at')
|
||||
list_filter = ('language', 'is_approved')
|
||||
search_fields = ('name',)
|
||||
filter_horizontal = ('color_tags', 'occasion_tags', 'style_tags')
|
||||
actions = ['approve_selected', 'reject_selected']
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'language', 'is_approved')
|
||||
}),
|
||||
('Теги', {
|
||||
'fields': ('color_tags', 'occasion_tags', 'style_tags')
|
||||
}),
|
||||
('Статистика', {
|
||||
'fields': ('usage_count', 'generated_at', 'approved_at')
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ('usage_count', 'generated_at', 'approved_at')
|
||||
|
||||
def approve_selected(self, request, queryset):
|
||||
from django.db import models
|
||||
queryset.update(is_approved=True, approved_at=models.DateTimeField(auto_now=True))
|
||||
self.message_user(request, "Выбранные названия были одобрены")
|
||||
|
||||
approve_selected.short_description = "Одобрить выбранные названия"
|
||||
|
||||
def reject_selected(self, request, queryset):
|
||||
queryset.update(is_approved=False, approved_at=None)
|
||||
self.message_user(request, "Выбранные названия были отклонены")
|
||||
|
||||
reject_selected.short_description = "Отклонить выбранные названия"
|
||||
|
||||
33
myproject/products/migrations/0002_bouquetname.py
Normal file
33
myproject/products/migrations/0002_bouquetname.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-22 10:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_add_sales_unit_to_kititem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BouquetName',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Название букета')),
|
||||
('language', models.CharField(default='russian', max_length=10, verbose_name='Язык')),
|
||||
('is_approved', models.BooleanField(default=False, verbose_name='Одобрено для использования')),
|
||||
('usage_count', models.PositiveIntegerField(default=0, verbose_name='Количество использований')),
|
||||
('generated_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата генерации')),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата одобрения')),
|
||||
('color_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_color', to='products.producttag', verbose_name='Цветные теги')),
|
||||
('occasion_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_occasion', to='products.producttag', verbose_name='Теги по поводу')),
|
||||
('style_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_style', to='products.producttag', verbose_name='Теги по стилю')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Название букета',
|
||||
'verbose_name_plural': 'Названия букетов',
|
||||
'indexes': [models.Index(fields=['language', 'is_approved'], name='products_bo_languag_8622de_idx'), models.Index(fields=['usage_count'], name='products_bo_usage_c_4ce5b8_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -49,6 +49,9 @@ from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPho
|
||||
# Задачи импорта
|
||||
from .import_job import ProductImportJob
|
||||
|
||||
# Названия букетов
|
||||
from .bouquet_names import BouquetName
|
||||
|
||||
# Явно указываем, что экспортируется при импорте *
|
||||
__all__ = [
|
||||
# Managers
|
||||
@@ -98,4 +101,7 @@ __all__ = [
|
||||
|
||||
# Import Jobs
|
||||
'ProductImportJob',
|
||||
|
||||
# Bouquet Names
|
||||
'BouquetName',
|
||||
]
|
||||
|
||||
73
myproject/products/models/bouquet_names.py
Normal file
73
myproject/products/models/bouquet_names.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from django.db import models
|
||||
from .categories import ProductTag
|
||||
|
||||
|
||||
class BouquetName(models.Model):
|
||||
"""
|
||||
Модель для хранения предопределенных названий букетов с метаинформацией
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
verbose_name="Название букета"
|
||||
)
|
||||
|
||||
# Категории характеристик
|
||||
color_tags = models.ManyToManyField(
|
||||
ProductTag,
|
||||
blank=True,
|
||||
related_name='bouquet_names_by_color',
|
||||
verbose_name="Цветные теги"
|
||||
)
|
||||
|
||||
occasion_tags = models.ManyToManyField(
|
||||
ProductTag,
|
||||
blank=True,
|
||||
related_name='bouquet_names_by_occasion',
|
||||
verbose_name="Теги по поводу"
|
||||
)
|
||||
|
||||
style_tags = models.ManyToManyField(
|
||||
ProductTag,
|
||||
blank=True,
|
||||
related_name='bouquet_names_by_style',
|
||||
verbose_name="Теги по стилю"
|
||||
)
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
default='russian',
|
||||
verbose_name="Язык"
|
||||
)
|
||||
|
||||
is_approved = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Одобрено для использования"
|
||||
)
|
||||
|
||||
usage_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Количество использований"
|
||||
)
|
||||
|
||||
generated_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата генерации"
|
||||
)
|
||||
|
||||
approved_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата одобрения"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Название букета"
|
||||
verbose_name_plural = "Названия букетов"
|
||||
indexes = [
|
||||
models.Index(fields=['language', 'is_approved']),
|
||||
models.Index(fields=['usage_count']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Сервисы для бизнес-логики products приложения.
|
||||
Следует принципу "Skinny Models, Fat Services".
|
||||
Следует принципу "Тонкие модели, толстые сервисы".
|
||||
"""
|
||||
from .unit_service import UnitOfMeasureService
|
||||
from .ai.bouquet_names import BouquetNameGenerator
|
||||
|
||||
__all__ = [
|
||||
'UnitOfMeasureService',
|
||||
'BouquetNameGenerator',
|
||||
]
|
||||
|
||||
6
myproject/products/services/ai/__init__.py
Normal file
6
myproject/products/services/ai/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
AI-сервисы для products приложения.
|
||||
|
||||
Содержит инструменты для взаимодействия с нейросетями для решения специфичных
|
||||
бизнес-задач, таких как генерация названий продуктов, описаний, классификация и т.д.
|
||||
"""
|
||||
48
myproject/products/services/ai/base.py
Normal file
48
myproject/products/services/ai/base.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple, Optional, Dict
|
||||
from integrations.services.ai_services.glm_service import GLMIntegrationService
|
||||
from integrations.services.ai_services.openrouter_service import OpenRouterIntegrationService
|
||||
from integrations.models.ai_services.glm import GLMIntegration
|
||||
from integrations.models.ai_services.openrouter import OpenRouterIntegration
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseAIProductService(ABC):
|
||||
"""
|
||||
Абстрактный базовый класс для AI-сервисов продуктов
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, **kwargs) -> Tuple[bool, str, Optional[Dict]]:
|
||||
"""
|
||||
Основной метод генерации
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_glm_service(cls) -> Optional[GLMIntegrationService]:
|
||||
"""
|
||||
Получить сервис GLM из активной интеграции
|
||||
"""
|
||||
try:
|
||||
integration = GLMIntegration.objects.filter(is_active=True).first()
|
||||
if integration:
|
||||
return GLMIntegrationService(integration)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении GLM сервиса: {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_openrouter_service(cls) -> Optional[OpenRouterIntegrationService]:
|
||||
"""
|
||||
Получить сервис OpenRouter из активной интеграции
|
||||
"""
|
||||
try:
|
||||
integration = OpenRouterIntegration.objects.filter(is_active=True).first()
|
||||
if integration:
|
||||
return OpenRouterIntegrationService(integration)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении OpenRouter сервиса: {str(e)}")
|
||||
return None
|
||||
208
myproject/products/services/ai/bouquet_names.py
Normal file
208
myproject/products/services/ai/bouquet_names.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from typing import Tuple, Optional, Dict, List
|
||||
from .base import BaseAIProductService
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BouquetNameGenerator(BaseAIProductService):
|
||||
"""
|
||||
Сервис для генерации и управления названиями букетов
|
||||
"""
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"Вы эксперт в создании красивых, привлекательных и продаваемых названий для букетов цветов. "
|
||||
"Ваша цель — генерировать запоминающиеся и выразительные названия, которые привлекут покупателей. "
|
||||
"Названия должны быть краткими (2-4 слов), креативными и соответствующими характеристикам букета. "
|
||||
"Избегайте общих терминов. Фокусируйтесь на эмоциях, эстетике"
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
count: int = 500,
|
||||
characteristics: Optional[str] = None,
|
||||
occasion: Optional[str] = None,
|
||||
language: str = "russian"
|
||||
) -> Tuple[bool, str, Optional[Dict]]:
|
||||
"""
|
||||
Генерация названий букетов
|
||||
|
||||
Args:
|
||||
count: Количество названий для генерации
|
||||
characteristics: Характеристики букетов (например, "розы, лилии, яркий")
|
||||
occasion: П'occasion (например, "день рождения, Valentine's Day")
|
||||
language: Язык генерации
|
||||
|
||||
Returns:
|
||||
Tuple: (success, message, data) где data содержит список названий
|
||||
"""
|
||||
logger.info(f"Генерация {count} названий для букетов")
|
||||
|
||||
# Получаем доступный AI-сервис
|
||||
glm_service = self.get_glm_service()
|
||||
if not glm_service:
|
||||
openrouter_service = self.get_openrouter_service()
|
||||
if not openrouter_service:
|
||||
return False, "Нет активных AI-интеграций", None
|
||||
service = openrouter_service
|
||||
else:
|
||||
service = glm_service
|
||||
|
||||
# Формируем промпт
|
||||
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
|
||||
|
||||
if characteristics:
|
||||
prompt += f" с следующими характеристиками: {characteristics}"
|
||||
|
||||
if occasion:
|
||||
prompt += f" для праздника: {occasion}"
|
||||
|
||||
prompt += (
|
||||
"\n\nТребования к каждому названию:\n"
|
||||
"- Точно 2-4 слова\n"
|
||||
"- Выразительные и эмоциональные\n"
|
||||
"- Продаваемые и запоминающиеся\n"
|
||||
"- Избегайте общих названий типа 'Букет #1'\n"
|
||||
"- Фокусируйтесь на красоте, романтике и подарках\n"
|
||||
"\nВерните названия в виде нумерованного списка, по одному на строку."
|
||||
)
|
||||
|
||||
# Вызов AI-сервиса
|
||||
success, msg, response = service.generate_text(
|
||||
prompt=prompt,
|
||||
system_prompt=self.DEFAULT_SYSTEM_PROMPT,
|
||||
max_tokens=3000 # Увеличиваем лимит для большего числа названий
|
||||
)
|
||||
|
||||
if not success:
|
||||
return False, msg, None
|
||||
|
||||
# Парсим результат
|
||||
names = self._parse_response(response.get('generated_text', ''))
|
||||
|
||||
return True, f"Сгенерировано {len(names)} названий для букетов", {
|
||||
'names': names,
|
||||
'model': response.get('model'),
|
||||
'usage': response.get('usage')
|
||||
}
|
||||
|
||||
def _parse_response(self, text: str) -> List[str]:
|
||||
"""
|
||||
Парсит текстовый ответ AI и извлекает названия букетов
|
||||
"""
|
||||
names = []
|
||||
lines = text.split('\n')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Пропускаем пустые строки и заголовки
|
||||
if not line or line.lower().startswith('here') or line.lower().startswith('names') or \
|
||||
line.lower().startswith('i\'m') or line.lower().startswith('sorry') or \
|
||||
line.lower().startswith('i hope') or line.lower().startswith('hope'):
|
||||
continue
|
||||
|
||||
# Удаляем номера списка
|
||||
if line and (line[0].isdigit() or line[0] == '-'):
|
||||
# Удаляем номер и точку или дефис
|
||||
if '.' in line:
|
||||
line = line.split('.', 1)[1].strip()
|
||||
else:
|
||||
line = line[1:].strip()
|
||||
|
||||
# Пропускаем строки, которые стали пустыми после удаления номера
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Удаляем markdown форматирование (жирный, курсив)
|
||||
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
|
||||
|
||||
if line:
|
||||
names.append(line)
|
||||
|
||||
# Удаляем дубликаты
|
||||
unique_names = []
|
||||
seen = set()
|
||||
for name in names:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
unique_names.append(name)
|
||||
|
||||
return unique_names
|
||||
|
||||
def generate_and_store(
|
||||
self,
|
||||
count: int = 500,
|
||||
characteristics: Optional[str] = None,
|
||||
occasion: Optional[str] = None,
|
||||
language: str = "russian"
|
||||
) -> Tuple[bool, str, Optional[Dict]]:
|
||||
"""
|
||||
Генерирует названия и сохраняет в базу данных
|
||||
"""
|
||||
from products.models import BouquetName
|
||||
|
||||
success, msg, data = self.generate(count, characteristics, occasion, language)
|
||||
|
||||
if success and data:
|
||||
# Сохраняем названия в базу
|
||||
stored_count = 0
|
||||
for name in data['names']:
|
||||
BouquetName.objects.get_or_create(
|
||||
name=name,
|
||||
language=language,
|
||||
defaults={
|
||||
'is_approved': False
|
||||
}
|
||||
)
|
||||
stored_count += 1
|
||||
|
||||
return True, f"Сгенерировано и сохранено {stored_count} названий для букетов", data
|
||||
|
||||
return success, msg, data
|
||||
|
||||
def get_approved_names(
|
||||
self,
|
||||
color_tags: Optional[List[str]] = None,
|
||||
occasion_tags: Optional[List[str]] = None,
|
||||
style_tags: Optional[List[str]] = None,
|
||||
language: str = "russian",
|
||||
limit: int = 100
|
||||
) -> List[str]:
|
||||
"""
|
||||
Получает одобренные названия с фильтрацией по тегам
|
||||
"""
|
||||
from products.models import BouquetName
|
||||
|
||||
queryset = BouquetName.objects.filter(
|
||||
is_approved=True,
|
||||
language=language
|
||||
)
|
||||
|
||||
if color_tags:
|
||||
queryset = queryset.filter(color_tags__name__in=color_tags)
|
||||
|
||||
if occasion_tags:
|
||||
queryset = queryset.filter(occasion_tags__name__in=occasion_tags)
|
||||
|
||||
if style_tags:
|
||||
queryset = queryset.filter(style_tags__name__in=style_tags)
|
||||
|
||||
# Сортируем по популярности
|
||||
queryset = queryset.order_by('-usage_count')
|
||||
|
||||
return list(queryset.values_list('name', flat=True)[:limit])
|
||||
|
||||
def mark_as_used(self, name: str, language: str = "russian") -> None:
|
||||
"""
|
||||
Увеличивает счетчик использования названия
|
||||
"""
|
||||
from products.models import BouquetName
|
||||
|
||||
BouquetName.objects.filter(
|
||||
name=name,
|
||||
language=language
|
||||
).update(
|
||||
usage_count=models.F('usage_count') + 1
|
||||
)
|
||||
@@ -86,6 +86,62 @@
|
||||
|
||||
<!-- ПРАВАЯ КОЛОНКА: Настройки -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Генератор названия -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-transparent border-bottom-0 p-3 d-flex justify-content-between align-items-center cursor-pointer"
|
||||
data-bs-toggle="collapse" data-bs-target="#nameGeneratorCollapse" aria-expanded="false">
|
||||
<h6 class="mb-0 text-muted"><i class="bi bi-lightbulb me-1"></i>Генератор названия</h6>
|
||||
<i class="bi bi-chevron-down text-muted"></i>
|
||||
</div>
|
||||
<div id="nameGeneratorCollapse" class="collapse">
|
||||
<div class="card-body p-3">
|
||||
<p class="small text-muted mb-3">
|
||||
Сгенерируйте привлекательное название для вашего букета автоматически
|
||||
<br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{ bouquet_names_count }}</span> названий</span>
|
||||
</p>
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-magic"></i> Пополнить базу названиями
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-refresh"></i> Дать три варианта
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Предложения названий -->
|
||||
<div class="name-suggestions">
|
||||
<!-- Строка 1 -->
|
||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||
<span class="text-muted small">Романтический букет роз</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-success btn-xs">Взять</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Строка 2 -->
|
||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||
<span class="text-muted small">Солнечный букет подсолнухов</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-success btn-xs">Взять</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Строка 3 -->
|
||||
<div class="d-flex justify-content-between align-items-center py-2">
|
||||
<span class="text-muted small">Элегантный букет лотосов</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-success btn-xs">Взять</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-xs">Потом</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-xs">Убрать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Расчет цены -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body p-3">
|
||||
@@ -410,6 +466,50 @@
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
/* Стили для генератора названий */
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-header[data-bs-toggle="collapse"]:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.card-header[data-bs-toggle="collapse"] .bi-chevron-down {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse.show .card-header[data-bs-toggle="collapse"] .bi-chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Кнопки очень маленького размера */
|
||||
.btn-xs {
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.btn-xs:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Стили для списка предложений */
|
||||
.name-suggestions {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name-suggestions .text-muted {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.name-suggestions .border-bottom {
|
||||
border-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
/* Стили для полей корректировки цены */
|
||||
#id_increase_percent:disabled,
|
||||
#id_increase_amount:disabled,
|
||||
@@ -1311,6 +1411,173 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== ГЕНЕРАТОР НАЗВАНИЙ ==========
|
||||
// Обработчик для кнопок "Взять"
|
||||
document.querySelectorAll('.name-suggestions .btn-success').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const suggestionText = this.closest('.d-flex').querySelector('.text-muted').textContent;
|
||||
const nameInput = document.getElementById('id_name');
|
||||
if (nameInput) {
|
||||
nameInput.value = suggestionText;
|
||||
// Улучшаем визуальный эффект
|
||||
nameInput.style.borderColor = '#198754';
|
||||
nameInput.style.boxShadow = '0 0 0 0.25rem rgba(25, 135, 84, 0.15)';
|
||||
setTimeout(() => {
|
||||
nameInput.style.borderColor = '';
|
||||
nameInput.style.boxShadow = '';
|
||||
}, 2000);
|
||||
}
|
||||
// Закрываем collapse
|
||||
const collapse = document.getElementById('nameGeneratorCollapse');
|
||||
const bsCollapse = new bootstrap.Collapse(collapse, { toggle: false });
|
||||
bsCollapse.hide();
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчик для кнопок "Убрать"
|
||||
document.querySelectorAll('.name-suggestions .btn-outline-danger').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
this.closest('.d-flex').remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчик для кнопок "Потом"
|
||||
document.querySelectorAll('.name-suggestions .btn-outline-secondary').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const row = this.closest('.d-flex');
|
||||
row.style.opacity = '0.5';
|
||||
row.style.textDecoration = 'line-through';
|
||||
setTimeout(() => {
|
||||
row.style.opacity = '1';
|
||||
row.style.textDecoration = 'none';
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== ГЕНЕРАТОР НАЗВАНИЙ - НОВЫЕ ОБРАБОТЧИКИ ==========
|
||||
|
||||
// Обработчик для кнопки "Сгенерировать" (LLM)
|
||||
const generateBtn = document.querySelector('#nameGeneratorCollapse .btn-outline-primary');
|
||||
if (generateBtn) {
|
||||
generateBtn.addEventListener('click', async function() {
|
||||
const originalHTML = generateBtn.innerHTML;
|
||||
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Генерация...';
|
||||
generateBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch("{% url 'products:api-generate-bouquet-names' %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({'count': 100})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Обновляем счётчик
|
||||
updateBouquetNamesCount();
|
||||
// Загружаем случайные 3
|
||||
await loadRandomNames();
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка при генерации названий. Проверьте, что настроена AI-интеграция.');
|
||||
} finally {
|
||||
generateBtn.innerHTML = originalHTML;
|
||||
generateBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Обработчик для кнопки "Случайное"
|
||||
const randomBtn = document.querySelector('#nameGeneratorCollapse .btn-outline-secondary');
|
||||
if (randomBtn) {
|
||||
randomBtn.addEventListener('click', loadRandomNames);
|
||||
}
|
||||
|
||||
async function loadRandomNames() {
|
||||
const originalHTML = randomBtn.innerHTML;
|
||||
randomBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
|
||||
randomBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=3");
|
||||
const data = await response.json();
|
||||
|
||||
if (data.names && data.names.length > 0) {
|
||||
updateNameSuggestions(data.names);
|
||||
} else {
|
||||
alert('В базе пока нет названий. Сначала запустите загрузку из JSON или нажмите "Сгенерировать"');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка при загрузке названий');
|
||||
} finally {
|
||||
randomBtn.innerHTML = originalHTML;
|
||||
randomBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateBouquetNamesCount() {
|
||||
try {
|
||||
const response = await fetch("{% url 'products:api-random-bouquet-names' %}?count=999");
|
||||
const data = await response.json();
|
||||
const countEl = document.getElementById('bouquetNamesCount');
|
||||
if (countEl) countEl.textContent = data.names.length;
|
||||
} catch (error) {
|
||||
console.error('Error updating count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateNameSuggestions(names) {
|
||||
const container = document.querySelector('.name-suggestions');
|
||||
container.innerHTML = '';
|
||||
|
||||
names.forEach((name, index) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'd-flex justify-content-between align-items-center py-2' +
|
||||
(index < names.length - 1 ? ' border-bottom' : '');
|
||||
row.innerHTML = `
|
||||
<span class="text-muted small">${name}</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-success btn-xs btn-apply-name">Взять</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-xs btn-remove-name">Убрать</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
attachNameButtonHandlers();
|
||||
}
|
||||
|
||||
function attachNameButtonHandlers() {
|
||||
document.querySelectorAll('.btn-apply-name').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const name = this.closest('.d-flex').querySelector('.text-muted').textContent;
|
||||
const nameInput = document.getElementById('id_name');
|
||||
if (nameInput) {
|
||||
nameInput.value = name;
|
||||
nameInput.style.borderColor = '#198754';
|
||||
nameInput.style.boxShadow = '0 0 0 0.25rem rgba(25, 135, 84, 0.15)';
|
||||
setTimeout(() => {
|
||||
nameInput.style.borderColor = '';
|
||||
nameInput.style.boxShadow = '';
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-remove-name').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
this.closest('.d-flex').remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========== ВАЛИДАЦИЯ ПЕРЕД ОТПРАВКОЙ ==========
|
||||
const kitForm = document.querySelector('form[method="post"]');
|
||||
if (kitForm) {
|
||||
|
||||
290
myproject/products/tests/test_ai_bouquet_names.py
Normal file
290
myproject/products/tests/test_ai_bouquet_names.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from django_tenants.test.cases import TenantTestCase
|
||||
from products.services import BouquetNameGenerator
|
||||
from products.models import BouquetName, ProductTag
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class BouquetNameGeneratorTestCase(TenantTestCase):
|
||||
"""
|
||||
Тесты для сервиса генерации названий букетов
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Создаем экземпляр сервиса для тестирования
|
||||
"""
|
||||
self.generator = BouquetNameGenerator()
|
||||
|
||||
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||
def test_generate_with_mock_glm(self, mock_get_glm_service):
|
||||
"""
|
||||
Тест генерации названий с мок-объектом GLM сервиса
|
||||
"""
|
||||
# Создаем мок-объект сервиса
|
||||
mock_service = MagicMock()
|
||||
mock_service.generate_text.return_value = (
|
||||
True,
|
||||
"Текст успешно сгенерирован",
|
||||
{
|
||||
'generated_text': (
|
||||
"1. Розавая мечта\n"
|
||||
"2. Лиловые настроения\n"
|
||||
"3. Яркий букет для дня рождения\n"
|
||||
"4. Сладкий сюрприз\n"
|
||||
"5. Романтическое вдохновение"
|
||||
),
|
||||
'model': 'glm-4',
|
||||
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
|
||||
}
|
||||
)
|
||||
mock_get_glm_service.return_value = mock_service
|
||||
|
||||
# Вызываем метод генерации
|
||||
success, msg, data = self.generator.generate(count=5)
|
||||
|
||||
# Проверки
|
||||
self.assertTrue(success)
|
||||
self.assertIn("Сгенерировано 5 названий для букетов", msg)
|
||||
self.assertIsNotNone(data)
|
||||
self.assertIn('names', data)
|
||||
self.assertEqual(len(data['names']), 5)
|
||||
self.assertEqual(data['model'], 'glm-4')
|
||||
self.assertIn('usage', data)
|
||||
|
||||
# Проверяем, что названия содержат нужные слова
|
||||
expected_names = [
|
||||
"Розавая мечта",
|
||||
"Лиловые настроения",
|
||||
"Яркий букет для дня рождения",
|
||||
"Сладкий сюрприз",
|
||||
"Романтическое вдохновение"
|
||||
]
|
||||
self.assertEqual(data['names'], expected_names)
|
||||
|
||||
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_openrouter_service')
|
||||
def test_no_active_integration(self, mock_get_openrouter, mock_get_glm):
|
||||
"""
|
||||
Тест случая, когда нет активных интеграций
|
||||
"""
|
||||
mock_get_glm.return_value = None
|
||||
mock_get_openrouter.return_value = None
|
||||
|
||||
success, msg, data = self.generator.generate(count=10)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(msg, "Нет активных AI-интеграций")
|
||||
self.assertIsNone(data)
|
||||
|
||||
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||
def test_generate_with_characteristics(self, mock_get_glm_service):
|
||||
"""
|
||||
Тест генерации с характеристиками
|
||||
"""
|
||||
# Создаем мок-объект сервиса
|
||||
mock_service = MagicMock()
|
||||
mock_service.generate_text.return_value = (
|
||||
True,
|
||||
"Текст успешно сгенерирован",
|
||||
{
|
||||
'generated_text': (
|
||||
"1. Ромашковое небо\n"
|
||||
"2. Лавандовый спокойствие\n"
|
||||
"3. Свежие ароматы\n"
|
||||
"4. Милая композиция\n"
|
||||
"5. Нежный букет"
|
||||
),
|
||||
'model': 'glm-4',
|
||||
'usage': {'prompt_tokens': 120, 'completion_tokens': 45}
|
||||
}
|
||||
)
|
||||
mock_get_glm_service.return_value = mock_service
|
||||
|
||||
success, msg, data = self.generator.generate(
|
||||
count=5,
|
||||
characteristics="ромашки, лаванда, свежие",
|
||||
occasion="день матери"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertIn("Сгенерировано 5 названий для букетов", msg)
|
||||
self.assertEqual(len(data['names']), 5)
|
||||
# Проверяем, что сервис был вызван с нужными параметрами
|
||||
mock_service.generate_text.assert_called_once()
|
||||
|
||||
def test_parse_response_with_markdown(self):
|
||||
"""
|
||||
Тест парсинга ответа с Markdown форматированием
|
||||
"""
|
||||
response_text = """
|
||||
Here are 3 beautiful bouquet names for you:
|
||||
|
||||
1. **Spring Blossom Delight**
|
||||
2. *Romantic Rose Elegance*
|
||||
3. "Sunny Daisy Joy"
|
||||
|
||||
I hope you love these!
|
||||
"""
|
||||
|
||||
names = self.generator._parse_response(response_text)
|
||||
self.assertEqual(len(names), 3)
|
||||
self.assertEqual(names[0], "Spring Blossom Delight")
|
||||
self.assertEqual(names[1], "Romantic Rose Elegance")
|
||||
self.assertEqual(names[2], "Sunny Daisy Joy")
|
||||
|
||||
def test_parse_response_with_duplicates(self):
|
||||
"""
|
||||
Тест парсинга ответа с дубликатами
|
||||
"""
|
||||
response_text = """
|
||||
1. Розавая мечта
|
||||
2. Лиловые настроения
|
||||
3. Розавая мечта
|
||||
4. Сладкий сюрприз
|
||||
5. Лиловые настроения
|
||||
"""
|
||||
|
||||
names = self.generator._parse_response(response_text)
|
||||
self.assertEqual(len(names), 3)
|
||||
self.assertIn("Розавая мечта", names)
|
||||
self.assertIn("Лиловые настроения", names)
|
||||
self.assertIn("Сладкий сюрприз", names)
|
||||
|
||||
def test_parse_response_empty(self):
|
||||
"""
|
||||
Тест парсинга пустого ответа
|
||||
"""
|
||||
response_text = """
|
||||
"""
|
||||
names = self.generator._parse_response(response_text)
|
||||
self.assertEqual(len(names), 0)
|
||||
|
||||
def test_parse_response_no_names(self):
|
||||
"""
|
||||
Тест парсинга ответа без названий
|
||||
"""
|
||||
response_text = """
|
||||
I'm sorry, but I can't help with that right now.
|
||||
"""
|
||||
names = self.generator._parse_response(response_text)
|
||||
self.assertEqual(len(names), 0)
|
||||
|
||||
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
|
||||
def test_generate_and_store(self, mock_get_glm_service):
|
||||
"""
|
||||
Тест генерации и сохранения названий в базе данных
|
||||
"""
|
||||
# Создаем мок-объект сервиса
|
||||
mock_service = MagicMock()
|
||||
mock_service.generate_text.return_value = (
|
||||
True,
|
||||
"Текст успешно сгенерирован",
|
||||
{
|
||||
'generated_text': (
|
||||
"1. Розавая мечта\n"
|
||||
"2. Лиловые настроения\n"
|
||||
"3. Яркий букет для дня рождения"
|
||||
),
|
||||
'model': 'glm-4',
|
||||
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
|
||||
}
|
||||
)
|
||||
mock_get_glm_service.return_value = mock_service
|
||||
|
||||
# Очищаем базу перед тестом
|
||||
BouquetName.objects.all().delete()
|
||||
|
||||
# Вызываем метод генерации и сохранения
|
||||
success, msg, data = self.generator.generate_and_store(count=3)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertIn("Сгенерировано и сохранено 3 названий для букетов", msg)
|
||||
self.assertEqual(BouquetName.objects.count(), 3)
|
||||
|
||||
def test_mark_as_used(self):
|
||||
"""
|
||||
Тест увеличения счетчика использования названия
|
||||
"""
|
||||
# Создаем тестовое название
|
||||
bouquet_name = BouquetName.objects.create(
|
||||
name="Тестовый букет",
|
||||
language="russian",
|
||||
is_approved=True
|
||||
)
|
||||
|
||||
# Проверяем начальное значение счетчика
|
||||
self.assertEqual(bouquet_name.usage_count, 0)
|
||||
|
||||
# Увеличиваем счетчик
|
||||
self.generator.mark_as_used("Тестовый букет", "russian")
|
||||
|
||||
# Проверяем обновленное значение
|
||||
bouquet_name.refresh_from_db()
|
||||
self.assertEqual(bouquet_name.usage_count, 1)
|
||||
|
||||
def test_get_approved_names(self):
|
||||
"""
|
||||
Тест получения одобренных названий
|
||||
"""
|
||||
# Очищаем базу перед тестом
|
||||
BouquetName.objects.all().delete()
|
||||
|
||||
# Создаем тестовые данные
|
||||
BouquetName.objects.create(
|
||||
name="Одобренный букет 1",
|
||||
language="russian",
|
||||
is_approved=True
|
||||
)
|
||||
BouquetName.objects.create(
|
||||
name="Одобренный букет 2",
|
||||
language="russian",
|
||||
is_approved=True
|
||||
)
|
||||
BouquetName.objects.create(
|
||||
name="Неодобренный букет",
|
||||
language="russian",
|
||||
is_approved=False
|
||||
)
|
||||
|
||||
# Получаем одобренные названия
|
||||
approved_names = self.generator.get_approved_names(language="russian")
|
||||
|
||||
self.assertEqual(len(approved_names), 2)
|
||||
self.assertIn("Одобренный букет 1", approved_names)
|
||||
self.assertIn("Одобренный букет 2", approved_names)
|
||||
self.assertNotIn("Неодобренный букет", approved_names)
|
||||
|
||||
def test_bouquet_name_model(self):
|
||||
"""
|
||||
Тест создания и работы с моделью BouquetName
|
||||
"""
|
||||
# Создаем тестовые теги
|
||||
red_tag = ProductTag.objects.create(name="красный", slug="krasny")
|
||||
romantic_tag = ProductTag.objects.create(name="романтический", slug="romanticheskiy")
|
||||
|
||||
# Создаем экземпляр модели
|
||||
bouquet_name = BouquetName.objects.create(
|
||||
name="Романтический букет",
|
||||
language="russian",
|
||||
is_approved=True
|
||||
)
|
||||
|
||||
# Добавляем теги
|
||||
bouquet_name.color_tags.add(red_tag)
|
||||
bouquet_name.style_tags.add(romantic_tag)
|
||||
|
||||
# Проверяем сохраненные значения
|
||||
self.assertEqual(bouquet_name.name, "Романтический букет")
|
||||
self.assertEqual(bouquet_name.language, "russian")
|
||||
self.assertTrue(bouquet_name.is_approved)
|
||||
self.assertEqual(bouquet_name.usage_count, 0)
|
||||
self.assertIn(red_tag, bouquet_name.color_tags.all())
|
||||
self.assertIn(romantic_tag, bouquet_name.style_tags.all())
|
||||
|
||||
# Обновляем поле
|
||||
bouquet_name.usage_count = 5
|
||||
bouquet_name.save()
|
||||
|
||||
# Проверяем обновление
|
||||
updated_name = BouquetName.objects.get(id=bouquet_name.id)
|
||||
self.assertEqual(updated_name.usage_count, 5)
|
||||
@@ -56,6 +56,8 @@ urlpatterns = [
|
||||
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
|
||||
path('api/filtered-items-ids/', api_views.get_filtered_items_ids, name='api-filtered-items-ids'),
|
||||
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
|
||||
path('api/bouquet-names/random/', api_views.RandomBouquetNamesView.as_view(), name='api-random-bouquet-names'),
|
||||
path('api/bouquet-names/generate/', api_views.GenerateBouquetNamesView.as_view(), name='api-generate-bouquet-names'),
|
||||
|
||||
# Photo processing status API (for AJAX polling)
|
||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||
|
||||
@@ -115,7 +115,14 @@ from .attribute_views import (
|
||||
)
|
||||
|
||||
# API представления
|
||||
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
|
||||
from .api_views import (
|
||||
search_products_and_variants,
|
||||
validate_kit_cost,
|
||||
create_temporary_kit_api,
|
||||
create_tag_api,
|
||||
RandomBouquetNamesView,
|
||||
GenerateBouquetNamesView,
|
||||
)
|
||||
|
||||
# Каталог
|
||||
from .catalog_views import CatalogView
|
||||
@@ -227,6 +234,8 @@ __all__ = [
|
||||
'validate_kit_cost',
|
||||
'create_temporary_kit_api',
|
||||
'create_tag_api',
|
||||
'RandomBouquetNamesView',
|
||||
'GenerateBouquetNamesView',
|
||||
|
||||
# Каталог
|
||||
'CatalogView',
|
||||
|
||||
@@ -1800,3 +1800,46 @@ def bulk_update_categories(request):
|
||||
'success': False,
|
||||
'message': f'Произошла ошибка: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
# ========== Генератор названий букетов ==========
|
||||
|
||||
from django.views import View
|
||||
from ..models import BouquetName
|
||||
from ..services import BouquetNameGenerator
|
||||
|
||||
|
||||
class RandomBouquetNamesView(View):
|
||||
"""Возвращает случайные названия из базы"""
|
||||
|
||||
def get(self, request):
|
||||
count = int(request.GET.get('count', 3))
|
||||
# Ограничиваем максимум до 100
|
||||
count = min(count, 100)
|
||||
names = list(BouquetName.objects.order_by('?')[:count].values_list('name', flat=True))
|
||||
return JsonResponse({'names': names})
|
||||
|
||||
|
||||
class GenerateBouquetNamesView(View):
|
||||
"""Генерирует новые названия через LLM и сохраняет в базу"""
|
||||
|
||||
def post(self, request):
|
||||
count = int(request.POST.get('count', 10))
|
||||
# Ограничиваем максимум до 500
|
||||
count = min(count, 500)
|
||||
|
||||
generator = BouquetNameGenerator()
|
||||
|
||||
success, msg, data = generator.generate_and_store(
|
||||
count=count,
|
||||
language='russian'
|
||||
)
|
||||
|
||||
if success:
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': msg,
|
||||
'count': len(data.get('names', []))
|
||||
})
|
||||
else:
|
||||
return JsonResponse({'success': False, 'error': msg}, status=400)
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.shortcuts import redirect
|
||||
from django.db import transaction, IntegrityError
|
||||
|
||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
|
||||
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName
|
||||
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||||
from .utils import handle_photos
|
||||
|
||||
@@ -197,6 +197,9 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
|
||||
else:
|
||||
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
|
||||
|
||||
# Количество названий букетов в базе
|
||||
context['bouquet_names_count'] = BouquetName.objects.count()
|
||||
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
Reference in New Issue
Block a user