From 34afd9569f4c524d9eef5230b93c100059692626 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 1 Oct 2026 19:23:13 +0300 Subject: [PATCH] =?UTF-8?q?:bug:=20fix(demo):=20=D0=BF=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=B2=D0=B5=D0=B1-=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../identity/demo_seed/loaders/webchat.py | 38 ++++++++++ .../identity/demo_seed/orchestrator.py | 4 + .../chatballs/identity/demo_seed/registry.py | 9 +++ .../chatballs/identity/test_seed_demo.py | 7 +- .../identity/test_seed_demo_webchat.py | 75 +++++++++++++++++++ docs/releases/1.16.0.md | 3 + 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/backend/chatballs/identity/demo_seed/loaders/webchat.py create mode 100644 apps/backend/chatballs/identity/test_seed_demo_webchat.py diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/webchat.py b/apps/backend/chatballs/identity/demo_seed/loaders/webchat.py new file mode 100644 index 0000000..b10a683 --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/loaders/webchat.py @@ -0,0 +1,38 @@ +"""Поля сайта и загруженные изображения из существующих данных веб-гостей.""" + +from io import BytesIO + +from django.core.files.uploadedfile import UploadedFile + +from chatballs.conversations.models import ContactFieldValue +from chatballs.identity.demo_seed import manifest +from chatballs.identity.demo_seed.refs import DemoRefs +from chatballs.tenancy.context import TenantContext +from chatballs.webchat.assets import upload_widget_asset + + +def load(context: TenantContext, refs: DemoRefs) -> None: + data = manifest.load("conversations", refs.language) + for item in data["conversations"]: + if not item.get("webGuest") or not item.get("guestName"): + continue + conversation = refs.conversations[item["key"]] + ContactFieldValue.objects.get_or_create( + organization=refs.organization, + contact=conversation.contact, + integration=conversation.connection, + key="name", + defaults={"value": item["guestName"]}, + ) + avatar = item.get("guestAvatar") + if avatar and not conversation.connection.widget_assets.exists(): + payload = manifest.media_bytes(f"avatars/{avatar}") + upload_widget_asset( + context=context, + integration=conversation.connection, + upload=UploadedFile( + file=BytesIO(payload), name=avatar, size=len(payload), + ), + ) + # Изображение доступно в хранилище, но не назначается кнопке или + # шапке: оформление демо-виджета остаётся по дизайн-базлайну. diff --git a/apps/backend/chatballs/identity/demo_seed/orchestrator.py b/apps/backend/chatballs/identity/demo_seed/orchestrator.py index f695050..c7a1e59 100644 --- a/apps/backend/chatballs/identity/demo_seed/orchestrator.py +++ b/apps/backend/chatballs/identity/demo_seed/orchestrator.py @@ -24,6 +24,9 @@ from chatballs.identity.demo_seed.loaders import ( from chatballs.identity.demo_seed.loaders import ( support as support_loader, ) +from chatballs.identity.demo_seed.loaders import ( + webchat as webchat_loader, +) from chatballs.identity.demo_seed.refs import DemoRefs from chatballs.tenancy.context import TenantContext @@ -52,5 +55,6 @@ def run_demo_seed( # Поддержка раньше диалогов: статьи портала привязываются к агентам. support_loader.load(context, refs) conversations_loader.load(context, refs) + webchat_loader.load(context, refs) operations_loader.load(context, refs) return refs diff --git a/apps/backend/chatballs/identity/demo_seed/registry.py b/apps/backend/chatballs/identity/demo_seed/registry.py index 1818452..d71fb14 100644 --- a/apps/backend/chatballs/identity/demo_seed/registry.py +++ b/apps/backend/chatballs/identity/demo_seed/registry.py @@ -24,6 +24,9 @@ from django.db.models.deletion import ProtectedError from django.db.models.signals import post_save from chatballs.identity.demo_models import DemoDataset, DemoRecord +from chatballs.tenancy.context import TenantContext +from chatballs.webchat.assets import discard_widget_asset_files +from chatballs.webchat.models import WidgetAsset logger = logging.getLogger(__name__) @@ -81,6 +84,12 @@ def recording(dataset: DemoDataset) -> Iterator[DemoRecorder]: def _delete_files(instance: models.Model) -> None: + if isinstance(instance, WidgetAsset): + discard_widget_asset_files( + context=TenantContext.for_resource(instance.organization), + files=[(instance.file.name, instance.size)] if instance.file.name else [], + ) + return for field in instance._meta.get_fields(): if isinstance(field, models.FileField): file = getattr(instance, field.name, None) diff --git a/apps/backend/chatballs/identity/test_seed_demo.py b/apps/backend/chatballs/identity/test_seed_demo.py index c2732ab..528e69c 100644 --- a/apps/backend/chatballs/identity/test_seed_demo.py +++ b/apps/backend/chatballs/identity/test_seed_demo.py @@ -13,6 +13,7 @@ from django.test import TestCase, override_settings from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment from chatballs.conversations.models import ( Contact, + ContactFieldValue, Conversation, LifecycleState, Message, @@ -72,12 +73,12 @@ def _counts() -> dict[str, int]: return {m._meta.label: m._base_manager.count() for m in _covered_models()} -@override_settings(MEDIA_ROOT=_MEDIA_ROOT) def maria_contact_fields(organization) -> tuple[str, str, str]: contact = Contact.objects.get(organization=organization, name="Мария Соколова") return contact.description, contact.company, contact.city +@override_settings(MEDIA_ROOT=_MEDIA_ROOT) class DemoDatasetTests(TestCase): def setUp(self) -> None: result = complete_setup(SetupInput(**OWNER)) @@ -279,6 +280,10 @@ class EnglishDemoDatasetTests(TestCase): self.assertTrue( AIAgent.objects.filter(organization=organization, name="Consultant").exists() ) + self.assertEqual( + ContactFieldValue.objects.get(organization=organization, key="name").value, + "Dmitry Orlov", + ) # Ни одной кириллической буквы во всём, что увидит человек на экране. texts = [ *Message.objects.filter(organization=organization).values_list("text", flat=True), diff --git a/apps/backend/chatballs/identity/test_seed_demo_webchat.py b/apps/backend/chatballs/identity/test_seed_demo_webchat.py new file mode 100644 index 0000000..cedab75 --- /dev/null +++ b/apps/backend/chatballs/identity/test_seed_demo_webchat.py @@ -0,0 +1,75 @@ +"""Новые модели веб-чата в демонаборе: реестр, повторный запуск и удаление.""" + +from tempfile import TemporaryDirectory + +from django.test import TestCase, override_settings + +from chatballs.conversations.models import ContactFieldValue +from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus +from chatballs.identity.demo_seed import manifest, service +from chatballs.identity.demo_seed.loaders import webchat +from chatballs.identity.demo_seed.refs import DemoRefs +from chatballs.identity.demo_seed.registry import _delete_record +from chatballs.identity.setup import SetupInput, complete_setup +from chatballs.identity.test_seed_demo import OWNER +from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.models import OrganizationStorageUsage +from chatballs.webchat.appearance import DEFAULTS +from chatballs.webchat.models import WidgetAsset + + +class DemoWebChatTests(TestCase): + def setUp(self) -> None: + media = TemporaryDirectory() + self.addCleanup(media.cleanup) + override = override_settings(MEDIA_ROOT=media.name) + override.enable() + self.addCleanup(override.disable) + result = complete_setup(SetupInput(**OWNER)) + self.context = TenantContext.for_resource(result.organization) + with tenant_atomic(self.context): + self.dataset = DemoDataset.objects.create( + organization=result.organization, status=DemoDatasetStatus.INSTALLING, + ) + self.dataset = service.install(context=self.context, dataset=self.dataset) + self.assertEqual(self.dataset.status, DemoDatasetStatus.INSTALLED, self.dataset.error) + + def test_web_guest_data_is_registered_without_changing_widget_appearance(self) -> None: + field = ContactFieldValue.objects.get(key="name") + self.assertEqual(field.value, field.contact.name) + asset = WidgetAsset.objects.get(integration=field.integration) + with tenant_atomic(self.context): + self.assertTrue(asset.file.storage.exists(asset.file.name)) + for instance in (field, asset): + self.assertTrue(self.dataset.records.filter( + content_type__model=instance._meta.model_name, + object_id=str(instance.pk), + ).exists()) + self.assertEqual(field.integration.config["appearance"], DEFAULTS) + # Ключ диалога берётся из манифеста, как при штатной установке. + item = next( + item for item in manifest.load("conversations")["conversations"] + if item.get("guestName") == field.value + ) + refs = DemoRefs( + organization=self.context.organization, + conversations={item["key"]: field.contact.conversations.get()}, + ) + with tenant_atomic(self.context): + webchat.load(self.context, refs) + self.assertEqual(ContactFieldValue.objects.count(), 1) + self.assertEqual(WidgetAsset.objects.count(), 1) + + def test_asset_removal_deletes_file_and_releases_its_storage_usage(self) -> None: + asset = WidgetAsset.objects.get() + path, storage, size = asset.file.name, asset.file.storage, asset.size + before = OrganizationStorageUsage.objects.get(organization=self.context.organization).bytes_used + record = self.dataset.records.get(content_type__model="widgetasset", object_id=str(asset.pk)) + with self.captureOnCommitCallbacks(execute=True), tenant_atomic(self.context): + self.assertTrue(_delete_record(record, set())) + with tenant_atomic(self.context): + self.assertFalse(storage.exists(path)) + self.assertFalse(WidgetAsset.objects.exists()) + after = OrganizationStorageUsage.objects.get(organization=self.context.organization).bytes_used + self.assertEqual(after, before - size) diff --git a/docs/releases/1.16.0.md b/docs/releases/1.16.0.md index 53a9130..f8ee829 100644 --- a/docs/releases/1.16.0.md +++ b/docs/releases/1.16.0.md @@ -25,6 +25,9 @@ - Dev-окружение задачи использует постоянный каталог PostgreSQL, Redis и media вне worktree. Скрипт проверяет исходные секреты и не инициализирует отсутствующую базу без явного `-InitializeDatabase`. +- Демо-набор покрывает новые модели полей контакта и изображений виджета. + Их записи учитываются в реестре; удаление изображения освобождает место + в хранилище. Текущее оформление демо-виджета сохраняется. ## Обновление