Исправление ошибки 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

@@ -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 = "Отклонить выбранные названия"

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 .bouquet_names import BouquetName
# Явно указываем, что экспортируется при импорте *
__all__ = [
# Managers
@@ -98,4 +101,7 @@ __all__ = [
# Import Jobs
'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
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
)

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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'),

View File

@@ -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',

View File

@@ -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)

View File

@@ -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):