Исправление ошибки POS: разрешено добавление в корзину для PlatformAdmin (использование session_id вместо пользователя). Включены изменения по AI названиям букетов.

This commit is contained in:
2026-01-22 20:29:05 +03:00
parent 01873be15d
commit 22e300394b
13 changed files with 739 additions and 20 deletions

View File

@@ -667,7 +667,16 @@ class ShowcaseItem(models.Model):
from datetime import timedelta from datetime import timedelta
self.status = 'in_cart' 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_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
self.cart_session_id = session_id self.cart_session_id = session_id
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at']) self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])

View File

@@ -263,13 +263,25 @@ def pos_terminal(request):
if showcase_item_ids: if showcase_item_ids:
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя # Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
locked_items = ShowcaseItem.objects.filter( from accounts.models import CustomUser
id__in=showcase_item_ids, if isinstance(request.user, CustomUser):
product_kit=kit, locked_items = ShowcaseItem.objects.filter(
status='in_cart', id__in=showcase_item_ids,
locked_by_user=request.user, product_kit=kit,
cart_lock_expires_at__gt=timezone.now() 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() locked_count = locked_items.count()
@@ -655,11 +667,22 @@ def remove_showcase_kit_from_cart(request, kit_id):
showcase_item_ids = [] showcase_item_ids = []
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем # Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
qs = ShowcaseItem.objects.filter( from accounts.models import CustomUser
product_kit=kit,
status='in_cart', if isinstance(request.user, CustomUser):
locked_by_user=request.user 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 - фильтруем только их # Если указаны конкретные ID - фильтруем только их
if showcase_item_ids: if showcase_item_ids:
@@ -710,10 +733,22 @@ def release_all_my_showcase_locks(request):
try: try:
# Снимаем ВСЕ блокировки текущего пользователя # Снимаем ВСЕ блокировки текущего пользователя
updated_count = ShowcaseItem.objects.filter( from accounts.models import CustomUser
status='in_cart',
locked_by_user=request.user if isinstance(request.user, CustomUser):
).update( 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', status='available',
locked_by_user=None, locked_by_user=None,
cart_lock_expires_at=None, cart_lock_expires_at=None,

View File

@@ -13,6 +13,7 @@ from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
from .models import UnitOfMeasure, ProductSalesUnit from .models import UnitOfMeasure, ProductSalesUnit
from .models import BouquetName
from .admin_displays import ( from .admin_displays import (
format_quality_badge, format_quality_badge,
format_quality_display, format_quality_display,
@@ -1086,3 +1087,42 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
count count
) )
get_options_count.short_description = 'Вариантов' 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 = "Отклонить выбранные названия"

View 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')],
},
),
]

View File

@@ -49,6 +49,9 @@ from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPho
# Задачи импорта # Задачи импорта
from .import_job import ProductImportJob from .import_job import ProductImportJob
# Названия букетов
from .bouquet_names import BouquetName
# Явно указываем, что экспортируется при импорте * # Явно указываем, что экспортируется при импорте *
__all__ = [ __all__ = [
# Managers # Managers
@@ -98,4 +101,7 @@ __all__ = [
# Import Jobs # Import Jobs
'ProductImportJob', 'ProductImportJob',
# Bouquet Names
'BouquetName',
] ]

View 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

View File

@@ -2,12 +2,14 @@ from typing import Tuple, Optional, Dict, List
from .base import BaseAIProductService from .base import BaseAIProductService
import logging import logging
from django.db import models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BouquetNameGenerator(BaseAIProductService): class BouquetNameGenerator(BaseAIProductService):
""" """
Сервис для генерации названий букетов с помощью AI Сервис для генерации и управления названиями букетов
""" """
DEFAULT_SYSTEM_PROMPT = ( DEFAULT_SYSTEM_PROMPT = (
@@ -128,3 +130,79 @@ class BouquetNameGenerator(BaseAIProductService):
unique_names.append(name) unique_names.append(name)
return unique_names 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
)

View File

@@ -86,6 +86,62 @@
<!-- ПРАВАЯ КОЛОНКА: Настройки --> <!-- ПРАВАЯ КОЛОНКА: Настройки -->
<div class="col-lg-4"> <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 border-0 shadow-sm mb-3">
<div class="card-body p-3"> <div class="card-body p-3">
@@ -410,6 +466,50 @@
color: #0d6efd; 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_percent:disabled,
#id_increase_amount: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"]'); const kitForm = document.querySelector('form[method="post"]');
if (kitForm) { if (kitForm) {

View File

@@ -1,5 +1,6 @@
from django_tenants.test.cases import TenantTestCase from django_tenants.test.cases import TenantTestCase
from products.services import BouquetNameGenerator from products.services import BouquetNameGenerator
from products.models import BouquetName, ProductTag
from unittest.mock import patch, MagicMock 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) names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 0) 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)

View File

@@ -56,6 +56,8 @@ urlpatterns = [
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'), 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/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/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) # Photo processing status API (for AJAX polling)
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'), path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),

View File

@@ -115,7 +115,14 @@ from .attribute_views import (
) )
# API представления # 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 from .catalog_views import CatalogView
@@ -227,6 +234,8 @@ __all__ = [
'validate_kit_cost', 'validate_kit_cost',
'create_temporary_kit_api', 'create_temporary_kit_api',
'create_tag_api', 'create_tag_api',
'RandomBouquetNamesView',
'GenerateBouquetNamesView',
# Каталог # Каталог
'CatalogView', 'CatalogView',

View File

@@ -1800,3 +1800,46 @@ def bulk_update_categories(request):
'success': False, 'success': False,
'message': f'Произошла ошибка: {str(e)}' 'message': f'Произошла ошибка: {str(e)}'
}, status=500) }, 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)

View File

@@ -9,7 +9,7 @@ from django.shortcuts import redirect
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from user_roles.mixins import ManagerOwnerRequiredMixin 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 ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
from .utils import handle_photos from .utils import handle_photos
@@ -197,6 +197,9 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
else: else:
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
# Количество названий букетов в базе
context['bouquet_names_count'] = BouquetName.objects.count()
return context return context
def form_valid(self, form): def form_valid(self, form):