diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py
index 8685d8e..b320408 100644
--- a/myproject/inventory/models.py
+++ b/myproject/inventory/models.py
@@ -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'])
diff --git a/myproject/pos/views.py b/myproject/pos/views.py
index 3402d4c..85d0584 100644
--- a/myproject/pos/views.py
+++ b/myproject/pos/views.py
@@ -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,
diff --git a/myproject/products/admin.py b/myproject/products/admin.py
index c7cc0f4..29e8372 100644
--- a/myproject/products/admin.py
+++ b/myproject/products/admin.py
@@ -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 = "Отклонить выбранные названия"
diff --git a/myproject/products/migrations/0002_bouquetname.py b/myproject/products/migrations/0002_bouquetname.py
new file mode 100644
index 0000000..9a8d1a2
--- /dev/null
+++ b/myproject/products/migrations/0002_bouquetname.py
@@ -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')],
+ },
+ ),
+ ]
diff --git a/myproject/products/models/__init__.py b/myproject/products/models/__init__.py
index 6fd455a..db93862 100644
--- a/myproject/products/models/__init__.py
+++ b/myproject/products/models/__init__.py
@@ -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',
]
diff --git a/myproject/products/models/bouquet_names.py b/myproject/products/models/bouquet_names.py
new file mode 100644
index 0000000..7e0fb06
--- /dev/null
+++ b/myproject/products/models/bouquet_names.py
@@ -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
diff --git a/myproject/products/services/ai/bouquet_names.py b/myproject/products/services/ai/bouquet_names.py
index 44a7757..2857380 100644
--- a/myproject/products/services/ai/bouquet_names.py
+++ b/myproject/products/services/ai/bouquet_names.py
@@ -2,12 +2,14 @@ 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):
"""
- Сервис для генерации названий букетов с помощью AI
+ Сервис для генерации и управления названиями букетов
"""
DEFAULT_SYSTEM_PROMPT = (
@@ -128,3 +130,79 @@ class BouquetNameGenerator(BaseAIProductService):
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
+ )
diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html
index cb69366..53df92b 100644
--- a/myproject/products/templates/products/productkit_create.html
+++ b/myproject/products/templates/products/productkit_create.html
@@ -86,6 +86,62 @@
+
+
+
+
+
+
+ Сгенерируйте привлекательное название для вашего букета автоматически
+
В базе: {{ bouquet_names_count }} названий
+
+
+
+
+
+
+
+
+
+
+
Романтический букет роз
+
+
+
+
+
+
+
+
+
Солнечный букет подсолнухов
+
+
+
+
+
+
+
+
+
Элегантный букет лотосов
+
+
+
+
+
+
+
+
+
+
+
@@ -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 = '
Генерация...';
+ 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 = '
Загрузка...';
+ 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 = `
+
${name}
+
+
+
+
+ `;
+ 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) {
diff --git a/myproject/products/tests/test_ai_bouquet_names.py b/myproject/products/tests/test_ai_bouquet_names.py
index 918086e..fe16050 100644
--- a/myproject/products/tests/test_ai_bouquet_names.py
+++ b/myproject/products/tests/test_ai_bouquet_names.py
@@ -1,5 +1,6 @@
from django_tenants.test.cases import TenantTestCase
from products.services import BouquetNameGenerator
+from products.models import BouquetName, ProductTag
from unittest.mock import patch, MagicMock
@@ -167,3 +168,123 @@ 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)
diff --git a/myproject/products/urls.py b/myproject/products/urls.py
index 82f5022..b60f8ff 100644
--- a/myproject/products/urls.py
+++ b/myproject/products/urls.py
@@ -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/
/', photo_status_api.photo_processing_status, name='api-photo-status'),
diff --git a/myproject/products/views/__init__.py b/myproject/products/views/__init__.py
index b372e4c..5ecaead 100644
--- a/myproject/products/views/__init__.py
+++ b/myproject/products/views/__init__.py
@@ -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',
diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py
index badaacd..472b080 100644
--- a/myproject/products/views/api_views.py
+++ b/myproject/products/views/api_views.py
@@ -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)
diff --git a/myproject/products/views/productkit_views.py b/myproject/products/views/productkit_views.py
index e26be53..9cd5553 100644
--- a/myproject/products/views/productkit_views.py
+++ b/myproject/products/views/productkit_views.py
@@ -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):