From 146052916b9a1351672f6b6db7fc42907a8d679e Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 8 Sep 2026 00:39:12 +0300 Subject: [PATCH] =?UTF-8?q?:fire:=20refactor:=20=D1=81=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=20=D1=81=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20Product?= =?UTF-8?q?=20=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20in-product=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B0=20(ADR-HUB-0045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Product` оставался в схеме «скрытой технической осью» (ADR-HUB-0041 §7), но из интерфейса не исчез: в настройках портала жил раздел «Продукты и поддержка», в списке порталов — колонка «Продукты». Решение владельца — убрать сущность целиком вместе со всем контуром, который на ней держался. Удалено: - приложения `products` и `support` (контракты идентификации, снимки личности, Product Support Token, публичные endpoints сессии); - `Channel.product` и `requires_authenticated_product_identity` (инварианты P3-P5; остались P1-P2), `LlmInvocation.product`, `SupportPortalProduct`; - `WebChatWidget.mode` целиком: различать было нечего, у веб-виджета одна анонимная точка входа. Ключ `mode` ушёл и из ответов API виджета; - `mode=support` и `ChatballsChat.init` в лоадере, `SupportApp` в web-chat; - capability `products.*`, аудит-действия `products.*`, продукты в карточке контакта и в статистике (`byProduct`); - раздел настроек портала и колонка списка — в коде и в дизайн-базлайне v2. У диалога один источник identity — контакт: XOR заменён на `conversation_requires_contact`. `PUT /portals/{id}/products/` удалён, `GET /portals/{id}/support-channels/` → `GET /portals/{id}/widgets/`. Миграции — по образцу сноса продаж: исторические миграции уцелевших приложений вычищены от ссылок (чистая установка объектов не создаёт), существующие БД чинит `tenancy/0029_drop_product_support`. Данные не экспортируются, но диалоги со снимком личности не удаляются — каждому создаётся контакт с именем из снимка. Проверено: backend 583 OK, фронт tsc + vitest 77 OK, сборки internal-ui и web-chat OK, миграция прогнана на локальной БД, публичный портал открывается. В коммит также вошли накопленные незакоммиченные правки рабочего дерева (библиотека знаний, настройки, dev-compose и nginx, макеты базлайна). Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/ai/agent_card.py | 1 - apps/backend/chatballs/ai/invocation.py | 4 - .../chatballs/ai/migrations/0001_initial.py | 12 - ...006_alter_aiagent_organization_and_more.py | 13 +- apps/backend/chatballs/ai/models.py | 5 +- apps/backend/chatballs/ai/selectors.py | 36 +- .../ai/test_knowledge_detail_payload.py | 28 + .../chatballs/ai/test_provider_modes.py | 2 - apps/backend/chatballs/ai/tests.py | 14 +- apps/backend/chatballs/ai/urls.py | 5 + apps/backend/chatballs/ai/views.py | 41 +- apps/backend/chatballs/channels/admin.py | 2 +- .../channels/migrations/0001_initial.py | 2 - ...annel_allow_anonymous_sessions_and_more.py | 5 - .../0005_enforce_policy_invariants.py | 32 +- apps/backend/chatballs/channels/models.py | 16 +- apps/backend/chatballs/channels/policy.py | 105 +- apps/backend/chatballs/channels/selectors.py | 4 +- apps/backend/chatballs/channels/services.py | 31 +- .../channels/test_policy_migration.py | 139 +-- .../chatballs/conversations/clients.py | 12 +- ...tion_support_identity_snapshot_and_more.py | 8 +- .../backend/chatballs/conversations/models.py | 22 +- .../chatballs/conversations/selectors.py | 2 - .../chatballs/conversations/serializers.py | 35 +- apps/backend/chatballs/conversations/stats.py | 20 +- apps/backend/chatballs/conversations/views.py | 1 - .../identity/administration_payloads.py | 4 - apps/backend/chatballs/identity/bootstrap.py | 3 - .../chatballs/identity/capabilities.py | 2 - .../identity/demo_seed/data/catalog.json | 7 - .../identity/demo_seed/data/channels_ai.json | 13 +- .../demo_seed/data/conversations.json | 1 - .../identity/demo_seed/data/support.json | 48 +- .../identity/demo_seed/loaders/catalog.py | 25 - .../identity/demo_seed/loaders/channels_ai.py | 3 - .../demo_seed/loaders/conversations.py | 5 +- .../identity/demo_seed/loaders/support.py | 67 +- .../chatballs/identity/demo_seed/manifest.py | 1 - .../identity/demo_seed/orchestrator.py | 9 +- .../chatballs/identity/demo_seed/refs.py | 3 - .../identity/migrations/0001_initial.py | 26 - .../migrations/0005_move_product_state.py | 10 +- ...loyeegroup_employeegroupmember_and_more.py | 1 - .../identity/test_administration_api.py | 8 +- apps/backend/chatballs/identity/tests.py | 52 +- apps/backend/chatballs/products/__init__.py | 1 - apps/backend/chatballs/products/admin.py | 10 - apps/backend/chatballs/products/apps.py | 7 - .../migrations/0001_move_product_state.py | 54 - ...es_description_product_summary_and_more.py | 118 -- .../0003_assign_existing_products_to_sales.py | 17 - ...price_uniq_price_offer_version_and_more.py | 21 - ...ve_price_price_amount_positive_and_more.py | 26 - .../0006_product_ingest_token_hash.py | 18 - .../0007_product_support_token_secret.py | 19 - .../0008_remove_product_knowledge_fields.py | 16 - ...tplacepublication_organization_and_more.py | 35 - ...tplacepublication_organization_and_more.py | 85 -- .../migrations/0011_remove_commerce.py | 20 - .../0012_delete_productdepartment.py | 18 - .../chatballs/products/migrations/__init__.py | 1 - apps/backend/chatballs/products/models.py | 34 - apps/backend/chatballs/products/selectors.py | 19 - .../backend/chatballs/products/serializers.py | 36 - apps/backend/chatballs/products/services.py | 51 - apps/backend/chatballs/products/tests.py | 75 -- apps/backend/chatballs/products/urls.py | 12 - apps/backend/chatballs/products/views.py | 135 --- apps/backend/chatballs/support/__init__.py | 0 apps/backend/chatballs/support/admin.py | 19 - apps/backend/chatballs/support/apps.py | 8 - apps/backend/chatballs/support/errors.py | 40 - apps/backend/chatballs/support/extract.py | 94 -- apps/backend/chatballs/support/jsonpath.py | 76 -- apps/backend/chatballs/support/messages.py | 278 ----- .../support/migrations/0001_initial.py | 77 -- .../0002_alter_productsupportcontract_code.py | 18 - .../chatballs/support/migrations/__init__.py | 0 apps/backend/chatballs/support/models.py | 139 --- apps/backend/chatballs/support/public_urls.py | 26 - .../backend/chatballs/support/public_views.py | 294 ----- apps/backend/chatballs/support/selectors.py | 61 - apps/backend/chatballs/support/serializers.py | 42 - apps/backend/chatballs/support/services.py | 98 -- apps/backend/chatballs/support/session.py | 257 ---- .../backend/chatballs/support/test_helpers.py | 55 - .../chatballs/support/test_widget_messages.py | 211 ---- apps/backend/chatballs/support/tests.py | 278 ----- apps/backend/chatballs/support/token.py | 125 -- apps/backend/chatballs/support/urls.py | 23 - apps/backend/chatballs/support/views.py | 146 --- .../chatballs/support/widget_credential.py | 76 -- .../chatballs/support_portals/admin.py | 2 - .../migrations/0002_content_and_products.py | 60 - .../0007_portal_widget_references.py | 17 - .../chatballs/support_portals/models.py | 68 -- .../support_portals/portal_services.py | 116 +- .../chatballs/support_portals/portal_views.py | 61 +- .../chatballs/support_portals/public_views.py | 9 +- .../chatballs/support_portals/selectors.py | 7 - .../chatballs/support_portals/serializers.py | 48 +- .../chatballs/support_portals/tests/base.py | 10 - .../tests/test_management_api.py | 75 +- .../tests/test_management_content_api.py | 9 +- .../support_portals/tests/test_public_api.py | 2 - .../backend/chatballs/support_portals/urls.py | 3 +- .../support_portals/widget_validation.py | 30 +- apps/backend/chatballs/tenancy/ingress.py | 34 - .../0002_cross_tenant_constraints.py | 19 +- .../tenancy/migrations/0003_rls_policies.py | 19 +- .../migrations/0004_ingress_directory.py | 19 +- .../migrations/0010_support_portal_guards.py | 20 +- .../0016_support_portal_widget_guards.py | 7 - .../migrations/0029_drop_product_support.py | 174 +++ apps/backend/chatballs/tenancy/test_rls.py | 35 +- apps/backend/chatballs/tenancy/tests.py | 41 +- apps/backend/chatballs/webchat/loader.py | 53 +- .../migrations/0005_remove_widget_mode.py | 17 + apps/backend/chatballs/webchat/models.py | 22 +- apps/backend/chatballs/webchat/services.py | 1 - apps/backend/chatballs/webchat/testing.py | 13 +- apps/backend/chatballs/webchat/views.py | 10 +- apps/backend/chatballs/webchat/widgets.py | 29 +- .../chatballs_backend/settings_base.py | 2 - apps/backend/chatballs_backend/urls_app.py | 9 +- apps/internal-ui/src/App.tsx | 20 +- apps/internal-ui/src/auth/access.test.ts | 4 +- .../ai/knowledge/CategoryManagement.tsx | 35 - .../ai/knowledge/KnowledgeAgentDialog.tsx | 129 ++ .../ai/knowledge/KnowledgeAttachmentsCard.tsx | 58 - .../ai/knowledge/KnowledgeBulkActions.tsx | 112 -- .../ai/knowledge/KnowledgeBulkBar.tsx | 53 + .../ai/knowledge/KnowledgeBulkDialog.tsx | 44 - .../ai/knowledge/KnowledgeCardPage.tsx | 322 +++++ .../ai/knowledge/KnowledgeCategoriesPage.tsx | 318 +++++ .../ai/knowledge/KnowledgeCategoryDialog.tsx | 73 ++ .../ai/knowledge/KnowledgeCategoryTree.tsx | 42 - .../ai/knowledge/KnowledgeCreatePage.tsx | 78 -- .../ai/knowledge/KnowledgeDetailPage.tsx | 129 -- .../ai/knowledge/KnowledgeDetailSidebar.tsx | 43 - .../knowledge/KnowledgeEditorBreadcrumb.tsx | 25 - .../ai/knowledge/KnowledgeEditorCard.tsx | 53 - .../ai/knowledge/KnowledgeEditorFields.tsx | 47 - .../ai/knowledge/KnowledgeEditorPage.tsx | 611 ++++++++++ .../ai/knowledge/KnowledgeImportModal.tsx | 90 -- .../ai/knowledge/KnowledgeImportPage.tsx | 280 +++++ .../ai/knowledge/KnowledgeLibraryPage.tsx | 483 ++++++++ .../features/ai/knowledge/KnowledgePage.tsx | 108 -- .../ai/knowledge/KnowledgePageHeader.tsx | 27 - .../features/ai/knowledge/KnowledgeTable.tsx | 128 -- .../ai/knowledge/KnowledgeTableFooter.tsx | 47 + .../ai/knowledge/KnowledgeToolbar.tsx | 28 - .../src/features/ai/knowledge/api.ts | 7 + .../ai/knowledge/knowledgeEditorModel.test.ts | 54 - .../ai/knowledge/knowledgeEditorModel.ts | 45 - .../ai/knowledge/knowledgeLibraryModel.ts | 142 +++ .../ai/knowledge/parseKnowledgeYaml.ts | 18 +- .../src/features/ai/knowledge/styles-bulk.css | 30 - .../src/features/ai/knowledge/styles-card.css | 453 +++++++ .../features/ai/knowledge/styles-category.css | 256 ---- .../features/ai/knowledge/styles-editor.css | 717 +++++++++-- .../features/ai/knowledge/styles-library.css | 1074 +++++++++++++++-- .../features/ai/knowledge/styles-pages.css | 445 +++++++ .../src/features/ai/knowledge/styles.css | 7 - .../src/features/ai/knowledge/types.ts | 2 + .../ai/knowledge/useKnowledgeLibrary.ts | 4 + .../src/features/chat/ChatPage.tsx | 17 +- .../features/conversations/ContextSection.tsx | 6 +- .../conversations/ConversationWorkspace.tsx | 2 +- .../src/features/conversations/model.ts | 38 +- .../src/features/conversations/types.ts | 1 - .../src/features/help-center/HelpLayout.tsx | 10 +- .../help-center/SupportLauncher.test.ts | 27 - .../features/help-center/SupportLauncher.tsx | 53 - .../src/features/help-center/types.ts | 10 - .../src/features/integrations/model.ts | 1 - .../src/features/settings/SettingsPage.tsx | 3 - .../support-portals/PortalArticleEditor.tsx | 4 +- .../support-portals/PortalProductSettings.tsx | 114 -- .../support-portals/PortalSettings.tsx | 23 +- .../SupportPortalDetailPage.tsx | 5 +- .../support-portals/SupportPortalsPage.tsx | 3 - .../src/features/support-portals/api.ts | 16 +- .../features/support-portals/markdownTools.ts | 116 +- .../src/features/support-portals/model.ts | 23 +- .../features/support-portals/portalText.ts | 6 - .../src/features/support-portals/sections.ts | 8 - .../support-portals/styles-detail.css | 8 +- .../features/support-portals/styles-list.css | 1 - .../support-portals/styles-settings.css | 33 - .../support/context/OperatorCards.tsx | 80 -- .../support/context/SupportHistory.tsx | 32 - apps/internal-ui/src/layout/Shell.tsx | 16 +- .../src/layout/ShellRouteContent.tsx | 52 +- apps/internal-ui/src/layout/Sidebar.tsx | 5 +- apps/internal-ui/src/layout/page.css | 19 - apps/internal-ui/src/router.test.ts | 33 +- apps/internal-ui/src/router.ts | 32 +- apps/internal-ui/src/routes.ts | 9 +- .../content-library/AgentLinkDialog.tsx | 27 +- .../content-library/BulkSelectionBar.tsx | 37 - .../CategoryManagementDialog.tsx | 12 +- .../content-library/CategoryManagementRow.tsx | 18 +- .../shared/content-library/CategoryTree.tsx | 153 --- .../ContentEditorBreadcrumb.tsx | 20 - .../content-library/ContentEditorCard.tsx | 27 - .../content-library/ContentLibraryTable.tsx | 63 - .../content-library/ContentLibraryToolbar.tsx | 25 - .../src/shared/content-library/styles.css | 197 +++ apps/internal-ui/src/shared/icons.tsx | 5 +- .../src/shared/markdown/markdownTools.ts | 119 ++ apps/internal-ui/src/shared/ui.tsx | 19 +- apps/internal-ui/src/shared/utils.ts | 10 - apps/internal-ui/src/styles.css | 8 +- apps/internal-ui/src/types.ts | 26 +- apps/internal-ui/src/useRouteNavigation.ts | 14 +- apps/internal-ui/vite.config.ts | 7 +- apps/web-chat/src/SupportApp.tsx | 232 ---- apps/web-chat/src/SupportStatusScreen.tsx | 43 - apps/web-chat/src/api.ts | 95 +- apps/web-chat/src/main.tsx | 8 +- apps/web-chat/vite.config.ts | 8 +- compose.dev.yaml | 5 + deploy/nginx/local.conf | 11 + .../baseline/Chatballs Baseline v2/README.md | 29 +- .../Агенты Baseline.dc.html | 3 +- .../Админ Baseline.dc.html | 3 +- .../База знаний Baseline.dc.html | 880 ++++++++++++++ .../Контакты Baseline.dc.html | 3 +- .../Настройки Baseline.dc.html | 3 +- .../Порталы Baseline.dc.html | 20 +- .../Сотрудники Baseline.dc.html | 1 + tests/e2e/internal-ui.spec.ts | 5 +- 234 files changed, 6883 insertions(+), 7895 deletions(-) delete mode 100644 apps/backend/chatballs/identity/demo_seed/data/catalog.json delete mode 100644 apps/backend/chatballs/identity/demo_seed/loaders/catalog.py delete mode 100644 apps/backend/chatballs/products/__init__.py delete mode 100644 apps/backend/chatballs/products/admin.py delete mode 100644 apps/backend/chatballs/products/apps.py delete mode 100644 apps/backend/chatballs/products/migrations/0001_move_product_state.py delete mode 100644 apps/backend/chatballs/products/migrations/0002_product_sales_description_product_summary_and_more.py delete mode 100644 apps/backend/chatballs/products/migrations/0003_assign_existing_products_to_sales.py delete mode 100644 apps/backend/chatballs/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py delete mode 100644 apps/backend/chatballs/products/migrations/0005_remove_price_price_amount_positive_and_more.py delete mode 100644 apps/backend/chatballs/products/migrations/0006_product_ingest_token_hash.py delete mode 100644 apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py delete mode 100644 apps/backend/chatballs/products/migrations/0008_remove_product_knowledge_fields.py delete mode 100644 apps/backend/chatballs/products/migrations/0009_marketplacepublication_organization_and_more.py delete mode 100644 apps/backend/chatballs/products/migrations/0010_alter_marketplacepublication_organization_and_more.py delete mode 100644 apps/backend/chatballs/products/migrations/0011_remove_commerce.py delete mode 100644 apps/backend/chatballs/products/migrations/0012_delete_productdepartment.py delete mode 100644 apps/backend/chatballs/products/migrations/__init__.py delete mode 100644 apps/backend/chatballs/products/models.py delete mode 100644 apps/backend/chatballs/products/selectors.py delete mode 100644 apps/backend/chatballs/products/serializers.py delete mode 100644 apps/backend/chatballs/products/services.py delete mode 100644 apps/backend/chatballs/products/tests.py delete mode 100644 apps/backend/chatballs/products/urls.py delete mode 100644 apps/backend/chatballs/products/views.py delete mode 100644 apps/backend/chatballs/support/__init__.py delete mode 100644 apps/backend/chatballs/support/admin.py delete mode 100644 apps/backend/chatballs/support/apps.py delete mode 100644 apps/backend/chatballs/support/errors.py delete mode 100644 apps/backend/chatballs/support/extract.py delete mode 100644 apps/backend/chatballs/support/jsonpath.py delete mode 100644 apps/backend/chatballs/support/messages.py delete mode 100644 apps/backend/chatballs/support/migrations/0001_initial.py delete mode 100644 apps/backend/chatballs/support/migrations/0002_alter_productsupportcontract_code.py delete mode 100644 apps/backend/chatballs/support/migrations/__init__.py delete mode 100644 apps/backend/chatballs/support/models.py delete mode 100644 apps/backend/chatballs/support/public_urls.py delete mode 100644 apps/backend/chatballs/support/public_views.py delete mode 100644 apps/backend/chatballs/support/selectors.py delete mode 100644 apps/backend/chatballs/support/serializers.py delete mode 100644 apps/backend/chatballs/support/services.py delete mode 100644 apps/backend/chatballs/support/session.py delete mode 100644 apps/backend/chatballs/support/test_helpers.py delete mode 100644 apps/backend/chatballs/support/test_widget_messages.py delete mode 100644 apps/backend/chatballs/support/tests.py delete mode 100644 apps/backend/chatballs/support/token.py delete mode 100644 apps/backend/chatballs/support/urls.py delete mode 100644 apps/backend/chatballs/support/views.py delete mode 100644 apps/backend/chatballs/support/widget_credential.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0029_drop_product_support.py create mode 100644 apps/backend/chatballs/webchat/migrations/0005_remove_widget_mode.py delete mode 100644 apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeAgentDialog.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeAttachmentsCard.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkBar.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeCardPage.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoriesPage.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryDialog.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorFields.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeImportPage.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgePageHeader.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeTableFooter.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx delete mode 100644 apps/internal-ui/src/features/ai/knowledge/knowledgeEditorModel.test.ts delete mode 100644 apps/internal-ui/src/features/ai/knowledge/knowledgeEditorModel.ts create mode 100644 apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts delete mode 100644 apps/internal-ui/src/features/ai/knowledge/styles-bulk.css create mode 100644 apps/internal-ui/src/features/ai/knowledge/styles-card.css delete mode 100644 apps/internal-ui/src/features/ai/knowledge/styles-category.css create mode 100644 apps/internal-ui/src/features/ai/knowledge/styles-pages.css delete mode 100644 apps/internal-ui/src/features/ai/knowledge/styles.css delete mode 100644 apps/internal-ui/src/features/help-center/SupportLauncher.test.ts delete mode 100644 apps/internal-ui/src/features/help-center/SupportLauncher.tsx delete mode 100644 apps/internal-ui/src/features/support-portals/PortalProductSettings.tsx delete mode 100644 apps/internal-ui/src/features/support/context/OperatorCards.tsx delete mode 100644 apps/internal-ui/src/features/support/context/SupportHistory.tsx delete mode 100644 apps/internal-ui/src/shared/content-library/BulkSelectionBar.tsx delete mode 100644 apps/internal-ui/src/shared/content-library/CategoryTree.tsx delete mode 100644 apps/internal-ui/src/shared/content-library/ContentEditorBreadcrumb.tsx delete mode 100644 apps/internal-ui/src/shared/content-library/ContentEditorCard.tsx delete mode 100644 apps/internal-ui/src/shared/content-library/ContentLibraryTable.tsx delete mode 100644 apps/internal-ui/src/shared/content-library/ContentLibraryToolbar.tsx create mode 100644 apps/internal-ui/src/shared/content-library/styles.css create mode 100644 apps/internal-ui/src/shared/markdown/markdownTools.ts delete mode 100644 apps/web-chat/src/SupportApp.tsx delete mode 100644 apps/web-chat/src/SupportStatusScreen.tsx create mode 100644 design/baseline/Chatballs Baseline v2/База знаний Baseline.dc.html diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 353b93c..ad51f7e 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -286,7 +286,6 @@ def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]: counts = ( ("conversations", channel.conversations.count()), ("connections", channel.connections.count()), - ("supportContracts", channel.allowed_support_contracts.count()), ("llmInvocations", channel.ai_invocations.count()), ) return [{"type": name, "count": count} for name, count in counts if count] diff --git a/apps/backend/chatballs/ai/invocation.py b/apps/backend/chatballs/ai/invocation.py index 04ebb21..90771bb 100644 --- a/apps/backend/chatballs/ai/invocation.py +++ b/apps/backend/chatballs/ai/invocation.py @@ -23,7 +23,6 @@ def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> N LlmInvocation.objects.create( organization=channel.organization, channel=channel, - product=channel.product, purpose=purpose, operation="chat", model=model, @@ -78,7 +77,6 @@ def invoke_chat( LlmInvocation.objects.create( organization=channel.organization, channel=channel, - product=channel.product, purpose=purpose, operation="chat", model=model, @@ -92,7 +90,6 @@ def invoke_chat( LlmInvocation.objects.create( organization=channel.organization, channel=channel, - product=channel.product, purpose=purpose, operation="chat", model=result.model, @@ -126,7 +123,6 @@ def embed_texts( LlmInvocation.objects.create( organization=channel.organization if channel else organization, channel=channel, - product=(channel.product if channel else None), purpose=purpose, operation="embedding", model=model, diff --git a/apps/backend/chatballs/ai/migrations/0001_initial.py b/apps/backend/chatballs/ai/migrations/0001_initial.py index 16d57b4..036a4f5 100644 --- a/apps/backend/chatballs/ai/migrations/0001_initial.py +++ b/apps/backend/chatballs/ai/migrations/0001_initial.py @@ -14,7 +14,6 @@ class Migration(migrations.Migration): dependencies = [ ('channels', '0002_channel_system_prompt'), ('identity', '0006_alter_employeeprofile_totp_secret'), - ('products', '0005_remove_price_price_amount_positive_and_more'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -69,7 +68,6 @@ class Migration(migrations.Migration): ('category', models.CharField(choices=[('OVERVIEW', 'Обзор продукта'), ('AUDIENCE', 'Целевая аудитория'), ('COMMERCIAL', 'Коммерческая модель'), ('TECHNICAL', 'Техническая информация'), ('FAQ', 'FAQ'), ('OBJECTIONS', 'Возражения'), ('LIMITATIONS', 'Ограничения')], max_length=32)), ('inclusion_mode', models.CharField(choices=[('MANDATORY', 'Обязательное включение'), ('RETRIEVAL', 'Доступно для retrieval')], default='RETRIEVAL', max_length=16)), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='identity.organization')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')), ], ), migrations.CreateModel( @@ -120,7 +118,6 @@ class Migration(migrations.Migration): ('used_fragment_ids', models.JSONField(blank=True, default=list)), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('channel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_invocations', to='channels.channel')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ai_invocations', to='products.product')), ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invocations', to='ai.channelairelease')), ], options={ @@ -139,7 +136,6 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ('category', models.CharField(choices=[('SYSTEM', 'Системный промпт'), ('QUALIFICATION', 'Квалификация'), ('SALES_BEHAVIOR', 'Поведение в продаже'), ('OPERATOR_HANDOFF', 'Передача оператору')], max_length=32)), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='identity.organization')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')), ], ), migrations.CreateModel( @@ -182,10 +178,6 @@ class Migration(migrations.Migration): model_name='channelairelease', constraint=models.UniqueConstraint(condition=models.Q(('status', 'PUBLISHED')), fields=('channel',), name='uniq_active_release_per_channel'), ), - migrations.AddConstraint( - model_name='knowledgedocument', - constraint=models.UniqueConstraint(fields=('organization', 'product', 'code'), name='uniq_knowledge_doc_org_product_code'), - ), migrations.AddConstraint( model_name='knowledgedocumentversion', constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_knowledge_version'), @@ -198,10 +190,6 @@ class Migration(migrations.Migration): model_name='llminvocation', index=models.Index(fields=['channel', 'created_at'], name='ai_llminvoc_channel_dbf239_idx'), ), - migrations.AddConstraint( - model_name='promptdocument', - constraint=models.UniqueConstraint(fields=('organization', 'product', 'code'), name='uniq_prompt_doc_org_product_code'), - ), migrations.AddConstraint( model_name='promptdocumentversion', constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_prompt_version'), diff --git a/apps/backend/chatballs/ai/migrations/0006_alter_aiagent_organization_and_more.py b/apps/backend/chatballs/ai/migrations/0006_alter_aiagent_organization_and_more.py index 3a66e6f..ea6b9d0 100644 --- a/apps/backend/chatballs/ai/migrations/0006_alter_aiagent_organization_and_more.py +++ b/apps/backend/chatballs/ai/migrations/0006_alter_aiagent_organization_and_more.py @@ -20,12 +20,9 @@ class Migration(migrations.Migration): SELECT 1 FROM ai_llminvocation invocation LEFT JOIN channels_channel channel ON channel.id = invocation.channel_id - LEFT JOIN identity_product product ON product.id = invocation.product_id - WHERE (channel.id IS NULL AND product.id IS NULL) - OR (channel.id IS NOT NULL AND product.id IS NOT NULL - AND channel.organization_id <> product.organization_id) + WHERE channel.id IS NULL ) THEN - RAISE EXCEPTION 'C04 preflight: ambiguous or cross-tenant LLM invocation exists'; + RAISE EXCEPTION 'C04 preflight: ambiguous LLM invocation exists'; END IF; END $$; @@ -48,12 +45,6 @@ class Migration(migrations.Migration): SET organization_id = channel.organization_id FROM channels_channel channel WHERE channel.id = invocation.channel_id; - - UPDATE ai_llminvocation invocation - SET organization_id = product.organization_id - FROM identity_product product - WHERE invocation.organization_id IS NULL - AND product.id = invocation.product_id; """, reverse_sql=migrations.RunSQL.noop, ), diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index 02679c7..ddbca09 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -232,10 +232,9 @@ class LlmInvocationStatus(models.TextChoices): class LlmInvocation(TenantRelationModel): - tenant_relation_fields = ("channel", "product") - # Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый. + tenant_relation_fields = ("channel",) + # Учёт по каналу (ADR-HUB-0019). channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations") - product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True) purpose = models.CharField(max_length=64) operation = models.CharField(max_length=16) # chat | embedding model = models.CharField(max_length=128, blank=True) diff --git a/apps/backend/chatballs/ai/selectors.py b/apps/backend/chatballs/ai/selectors.py index 31d0063..926a131 100644 --- a/apps/backend/chatballs/ai/selectors.py +++ b/apps/backend/chatballs/ai/selectors.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from dataclasses import dataclass from django.db.models import Count, Prefetch, Q, QuerySet @@ -6,13 +7,46 @@ from chatballs.ai.agent_knowledge import knowledge_available_to_channel from chatballs.ai.knowledge_policy import readable_knowledge, writable_knowledge from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory from chatballs.channels.models import Channel +from chatballs.identity.models import AuditEvent from chatballs.identity.policy import has_capability_any_scope from chatballs.tenancy.context import TenantContext +KNOWLEDGE_EDIT_ACTIONS = ("ai.knowledge_created", "ai.knowledge_updated") + + +def knowledge_editors( + *, organization_id: int, knowledge_ids: Sequence[int] +) -> dict[int, str]: + """Кто последним правил каждое знание — подпись под датой в колонке + «Обновлено» (дизайн-базлайн v2, кадр KB1). Один запрос на весь список: + события создания и правки уже пишутся в журнал.""" + if not knowledge_ids: + return {} + events = ( + AuditEvent.objects.filter( + organization_id=organization_id, + action__in=KNOWLEDGE_EDIT_ACTIONS, + object_type="Knowledge", + object_id__in=[str(knowledge_id) for knowledge_id in knowledge_ids], + ) + .select_related("actor") + .order_by("object_id", "-created_at") + ) + editors: dict[int, str] = {} + for event in events: + if event.actor is None: + continue + try: + knowledge_id = int(event.object_id) + except (TypeError, ValueError): + continue + editors.setdefault(knowledge_id, event.actor.full_name or event.actor.email) + return editors + def agents_for_context(context: TenantContext) -> QuerySet[AIAgent]: return ( - AIAgent.objects.select_related("channel", "channel__group", "channel__product") + AIAgent.objects.select_related("channel", "channel__group") .prefetch_related( "knowledge_items", "portal_articles__portal", diff --git a/apps/backend/chatballs/ai/test_knowledge_detail_payload.py b/apps/backend/chatballs/ai/test_knowledge_detail_payload.py index b540be0..7529ab0 100644 --- a/apps/backend/chatballs/ai/test_knowledge_detail_payload.py +++ b/apps/backend/chatballs/ai/test_knowledge_detail_payload.py @@ -29,3 +29,31 @@ class KnowledgeDetailPayloadTests(TestCase): self.assertTrue(detail["createdBy"]) self.assertGreater(detail["fragmentsCount"], 0) self.assertNotIn("createdBy", listed) + # Автор последней правки стоит под датой в колонке «Обновлено» + # (дизайн-базлайн v2, кадр KB1), поэтому он есть и в списке. + self.assertEqual(listed["updatedBy"], detail["updatedBy"]) + self.assertTrue(listed["updatedBy"]) + + +class KnowledgeReindexTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.client = APIClient() + self.client.login(username="owner@example.com", password="temporary-password") + organization = Organization.objects.get(slug="demo") + self.base = f"/api/v1/organizations/{organization.public_id}/ai/knowledge" + + def test_reindex_rebuilds_fragments_of_knowledge(self) -> None: + created = self.client.post( + f"{self.base}/", + data=json.dumps({"title": "Сроки", "content": "Рубашка — 10 дней.\n\nПальто — 25 дней."}), + content_type="application/json", + ) + knowledge_id = created.json()["knowledge"]["id"] + before = self.client.get(f"{self.base}/{knowledge_id}/").json()["knowledge"]["fragmentsCount"] + + response = self.client.post(f"{self.base}/{knowledge_id}/reindex/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["knowledge"]["fragmentsCount"], before) + self.assertGreater(before, 0) diff --git a/apps/backend/chatballs/ai/test_provider_modes.py b/apps/backend/chatballs/ai/test_provider_modes.py index 526f31e..139969d 100644 --- a/apps/backend/chatballs/ai/test_provider_modes.py +++ b/apps/backend/chatballs/ai/test_provider_modes.py @@ -20,7 +20,6 @@ from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import Organization from chatballs.integrations.models import Integration, IntegrationProvider from chatballs.integrations.services import IntegrationInput, create_integration -from chatballs.products.models import Product from chatballs.testing import system_tenant_context @@ -35,7 +34,6 @@ class ProviderModeTests(TestCase): self.organization, code="provider-mode-sales", name="Provider mode — продажи", - product=Product.objects.get(code="site"), ) def _link_integration( diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index e1c4707..363b88c 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -10,15 +10,14 @@ from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFrag from chatballs.channels.models import Channel from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership -from chatballs.products.models import Product _MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-") -def make_channel_with_agent(organization, *, code, name, product=None, model="openai/gpt-4o-mini"): +def make_channel_with_agent(organization, *, code, name, model="openai/gpt-4o-mini"): """Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт каналы/агентов — в тестах их собирает этот helper.""" - channel = Channel.objects.create(organization=organization, code=code, name=name, product=product) + channel = Channel.objects.create(organization=organization, code=code, name=name) agent = AIAgent.objects.create( channel=channel, name=f"{name} Agent", @@ -49,7 +48,6 @@ class AIAgentInvariantTests(TestCase): channel, _ = make_channel_with_agent( self.organization, code="site-sales", name="Сайт — продажи", - product=Product.objects.get(code="site"), ) with self.assertRaises(ValidationError): create_agent( @@ -64,11 +62,6 @@ class AIAgentInvariantTests(TestCase): ), ) - def test_new_product_does_not_get_an_agent_automatically(self) -> None: - product = Product.objects.create(organization=self.organization, code="academy", name="Academy") - self.assertFalse(AIAgent.objects.filter(channel__product=product).exists()) - - class AIAgentPermissionTests(TestCase): def setUp(self) -> None: bootstrap_owner(email="owner@example.com", password="temporary-password") @@ -328,7 +321,6 @@ class ChatInvocationTests(TestCase): self.organization = Organization.objects.get(slug="demo") self.channel, self.agent = make_channel_with_agent( self.organization, code="site-sales", name="Сайт — продажи", - product=Product.objects.get(code="site"), ) def test_chat_records_invocation_with_cost(self) -> None: @@ -447,7 +439,6 @@ class KnowledgeRetrievalTests(TestCase): self.organization = Organization.objects.get(slug="demo") self.channel, self.agent = make_channel_with_agent( self.organization, code="site-sales", name="Сайт — продажи", - product=Product.objects.get(code="site"), ) from chatballs.ai.services import KnowledgeInput, create_knowledge @@ -494,7 +485,6 @@ class AgentRuntimeTests(TestCase): self.organization = Organization.objects.get(slug="demo") self.channel, self.agent = make_channel_with_agent( self.organization, code="site-sales", name="Сайт — продажи", - product=Product.objects.get(code="site"), ) self.agent.persona = "Ты — ассистент Сайт." self.agent.tone = "Коротко." diff --git a/apps/backend/chatballs/ai/urls.py b/apps/backend/chatballs/ai/urls.py index 48a9219..991b3fd 100644 --- a/apps/backend/chatballs/ai/urls.py +++ b/apps/backend/chatballs/ai/urls.py @@ -41,6 +41,11 @@ urlpatterns = [ views.KnowledgeDetailView.as_view(), name="ai-knowledge-detail", ), + path( + "knowledge//reindex/", + views.KnowledgeReindexView.as_view(), + name="ai-knowledge-reindex", + ), path( "knowledge//attachments/", views.KnowledgeAttachmentUploadView.as_view(), diff --git a/apps/backend/chatballs/ai/views.py b/apps/backend/chatballs/ai/views.py index e2c476f..b67c8a6 100644 --- a/apps/backend/chatballs/ai/views.py +++ b/apps/backend/chatballs/ai/views.py @@ -18,9 +18,11 @@ from chatballs.ai.knowledge_services import ( delete_knowledge, update_knowledge, ) +from chatballs.ai.indexing import reindex_knowledge from chatballs.ai.models import Knowledge from chatballs.ai.selectors import ( apply_knowledge_filters, + knowledge_editors, knowledge_for_context, knowledge_item_for_context, writable_knowledge_item_for_employee, @@ -71,10 +73,21 @@ class KnowledgeListCreateView(_KnowledgeBaseView): filters = knowledge_filters(request) except ValidationError as error: return _validation_error(error) - items = apply_knowledge_filters(knowledge_for_context(request.tenant_context), filters) - return Response( - {"items": [knowledge_payload(item, include_content=False) for item in items]} + items = list(apply_knowledge_filters(knowledge_for_context(request.tenant_context), filters)) + # Автор последней правки нужен в списке: он стоит под датой в колонке + # «Обновлено» (кадр KB1). Один запрос на всю страницу, не N+1. + editors = knowledge_editors( + organization_id=self._org(request).id, + knowledge_ids=[item.id for item in items], ) + payloads = [] + for item in items: + payload = knowledge_payload(item, include_content=False) + editor = editors.get(item.id) + if editor: + payload["updatedBy"] = editor + payloads.append(payload) + return Response({"items": payloads}) def post(self, request: Request) -> Response: try: @@ -111,6 +124,12 @@ class KnowledgeDetailView(_KnowledgeBaseView): ) if created_event and created_event.actor: payload["createdBy"] = created_event.actor.full_name or created_event.actor.email + editor = knowledge_editors( + organization_id=knowledge.organization_id, + knowledge_ids=[knowledge.id], + ).get(knowledge.id) + if editor: + payload["updatedBy"] = editor return Response({"knowledge": payload}) def patch(self, request: Request, knowledge_id: int) -> Response: @@ -168,6 +187,22 @@ class KnowledgeImportView(_KnowledgeBaseView): return Response(payload, status=201) +class KnowledgeReindexView(_KnowledgeBaseView): + """Пересборка фрагментов знания по требованию (дизайн-базлайн v2, кадры + KB1/KB4). Обычно индекс перестраивается при сохранении; кнопка нужна, когда + агент отвечает старым текстом после сбоя или правки вложения.""" + + def post(self, request: Request, knowledge_id: int) -> Response: + try: + knowledge = self._write_knowledge(request, knowledge_id) + except Knowledge.DoesNotExist: + return Response({"detail": "Knowledge not found"}, status=404) + reindex_knowledge(knowledge) + self._audit(request, "reindexed", knowledge) + knowledge = self._write_knowledge(request, knowledge_id) + return Response({"knowledge": knowledge_payload(knowledge)}) + + class KnowledgeAttachmentUploadView(_KnowledgeBaseView): parser_classes = [MultiPartParser, FormParser, JSONParser] diff --git a/apps/backend/chatballs/channels/admin.py b/apps/backend/chatballs/channels/admin.py index 06d30ee..3db9514 100644 --- a/apps/backend/chatballs/channels/admin.py +++ b/apps/backend/chatballs/channels/admin.py @@ -5,6 +5,6 @@ from chatballs.channels.models import Channel @admin.register(Channel) class ChannelAdmin(admin.ModelAdmin): - list_display = ("name", "code", "product", "group", "is_active") + list_display = ("name", "code", "group", "is_active") list_filter = ("is_active",) search_fields = ("name", "code") diff --git a/apps/backend/chatballs/channels/migrations/0001_initial.py b/apps/backend/chatballs/channels/migrations/0001_initial.py index cb22c19..b08ffe9 100644 --- a/apps/backend/chatballs/channels/migrations/0001_initial.py +++ b/apps/backend/chatballs/channels/migrations/0001_initial.py @@ -11,7 +11,6 @@ class Migration(migrations.Migration): dependencies = [ ('identity', '0006_alter_employeeprofile_totp_secret'), ('integrations', '0001_initial'), - ('products', '0005_remove_price_price_amount_positive_and_more'), ] operations = [ @@ -28,7 +27,6 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='identity.department')), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='identity.organization')), - ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='products.product')), ('provider_integration', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='integrations.integration')), ], options={ diff --git a/apps/backend/chatballs/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py b/apps/backend/chatballs/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py index d55993d..2e6f12d 100644 --- a/apps/backend/chatballs/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py +++ b/apps/backend/chatballs/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py @@ -30,9 +30,4 @@ class Migration(migrations.Migration): name='allow_self_reported_contact', field=models.BooleanField(default=True), ), - migrations.AddField( - model_name='channel', - name='requires_authenticated_product_identity', - field=models.BooleanField(default=False), - ), ] diff --git a/apps/backend/chatballs/channels/migrations/0005_enforce_policy_invariants.py b/apps/backend/chatballs/channels/migrations/0005_enforce_policy_invariants.py index 2fe961f..1e448f7 100644 --- a/apps/backend/chatballs/channels/migrations/0005_enforce_policy_invariants.py +++ b/apps/backend/chatballs/channels/migrations/0005_enforce_policy_invariants.py @@ -1,37 +1,19 @@ # SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3. # -# Приводит существующие каналы в соответствие P1-P5 и меняет дефолты модели, -# которые сами по себе нарушали P1-P2: канал создаётся без продукта, а -# attribution и checkout были включены по умолчанию. +# Приводит существующие каналы в соответствие P1-P2 и меняет дефолты модели, +# которые сами по себе их нарушали: attribution и checkout были включены по +# умолчанию. # -# Решение владельца по унаследованным непродуктовым каналам: выключить -# checkout и attribution, а не назначать продукт. Поэтому миграция чинит ровно -# P1 и P2. Нарушения P3-P5 она не трогает и останавливает выкат: их -# исправление меняет смысл канала (обязательная идентичность), и выбирать за -# владельца здесь нельзя. +# Решение владельца по унаследованным каналам: выключить checkout и +# attribution. Продуктовая часть инвариантов (P3-P5) снята вместе с сущностью +# Product (ADR-HUB-0041). from django.db import migrations, models def relax_non_product_channels(apps, schema_editor): Channel = apps.get_model("channels", "Channel") - blocked = list( - Channel.objects.filter(requires_authenticated_product_identity=True) - .filter( - models.Q(product__isnull=True) - | models.Q(allow_anonymous_sessions=True) - | models.Q(allow_self_reported_contact=True) - ) - .values_list("id", "code") - ) - if blocked: - listed = ", ".join(f"{code} (id={channel_id})" for channel_id, code in blocked) - raise RuntimeError( - "Каналы нарушают P3-P5 и требуют решения владельца до включения " - f"инвариантов: {listed}" - ) - - Channel.objects.filter(product__isnull=True).filter( + Channel.objects.filter( models.Q(allow_checkout_actions=True) | models.Q(allow_sales_attribution=True) ).update(allow_checkout_actions=False, allow_sales_attribution=False) diff --git a/apps/backend/chatballs/channels/models.py b/apps/backend/chatballs/channels/models.py index 37a5aca..069ab6f 100644 --- a/apps/backend/chatballs/channels/models.py +++ b/apps/backend/chatballs/channels/models.py @@ -1,8 +1,8 @@ from django.db import models -# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Опциональный продукт, -# группа видимости, ссылка на провайдер-интеграцию. Поведение AI (модель, -# инструкции, знания) живёт на агенте канала (ADR-HUB-0023). +# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и +# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания) +# живёт на агенте канала (ADR-HUB-0023). class Channel(models.Model): @@ -12,16 +12,12 @@ class Channel(models.Model): # Группа видимости (ADR-HUB-0043): новые диалоги канала попадают в неё. # NULL — диалоги видны всем сотрудникам. group = models.ForeignKey("identity.EmployeeGroup", on_delete=models.SET_NULL, related_name="channels", null=True, blank=True) - # Продукт опционален: непродуктовый канал — сайт компании. - product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="channels", null=True, blank=True) # LLM-провайдер канала (ADR-HUB-0020). provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True) is_active = models.BooleanField(default=True) - # Политика канала (SPEC-HUB-0010 §4.2, SPEC-HUB-0027 §3.2). Дефолты обязаны - # удовлетворять инвариантам P1-P5 при product = null: канал создаётся без - # продукта, поэтому коммерческие флаги по умолчанию выключены. Продуктовый - # канал включает их явно — пресетом SALES или политикой в запросе. - requires_authenticated_product_identity = models.BooleanField(default=False) + # Политика канала: остаток от домена продаж — коммерческие флаги всегда + # выключены (ADR-HUB-0041/0045), анонимные сессии и самозаявленный контакт + # используются веб-виджетом и порталом. allow_anonymous_sessions = models.BooleanField(default=True) allow_self_reported_contact = models.BooleanField(default=True) allow_sales_attribution = models.BooleanField(default=False) diff --git a/apps/backend/chatballs/channels/policy.py b/apps/backend/chatballs/channels/policy.py index adcdebb..155b5ac 100644 --- a/apps/backend/chatballs/channels/policy.py +++ b/apps/backend/chatballs/channels/policy.py @@ -1,10 +1,8 @@ """Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7). -Источник истины — пять булевых полей `Channel`. Пресет существует только в API -и UI: он заполняет флаги при создании и полем модели не является. - -Инварианты выражают одно правило `ADR-HUB-0019`: непродуктовый канал не -формирует коммерческих действий. Проверяются по итоговому состоянию, а не по +Источник истины — булевы поля `Channel`. Продуктовой идентичности больше нет +(ADR-HUB-0045: сущность `Product` удалена), поэтому коммерческие действия и +attribution запрещены безусловно. Проверяются по итоговому состоянию, а не по переданным полям, — частичное применение запрещено. """ @@ -17,7 +15,6 @@ from django.core.exceptions import ValidationError from chatballs.channels.models import Channel POLICY_FIELDS = ( - "requires_authenticated_product_identity", "allow_anonymous_sessions", "allow_self_reported_contact", "allow_sales_attribution", @@ -26,7 +23,6 @@ POLICY_FIELDS = ( # Ключ payload -> имя поля модели (SPEC §6.1). POLICY_API_FIELDS = { - "requiresAuthenticatedProductIdentity": "requires_authenticated_product_identity", "allowAnonymousSessions": "allow_anonymous_sessions", "allowSelfReportedContact": "allow_self_reported_contact", "allowSalesAttribution": "allow_sales_attribution", @@ -34,15 +30,8 @@ POLICY_API_FIELDS = { } -class PolicyPreset: - SALES = "SALES" - SUPPORT = "SUPPORT" - CUSTOM = "CUSTOM" - - @dataclass(frozen=True, slots=True) class ChannelPolicy: - requires_authenticated_product_identity: bool allow_anonymous_sessions: bool allow_self_reported_contact: bool allow_sales_attribution: bool @@ -52,10 +41,6 @@ class ChannelPolicy: def from_channel(cls, channel: Channel) -> ChannelPolicy: return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS}) - @classmethod - def from_preset(cls, preset: str) -> ChannelPolicy: - return _PRESETS[preset] - def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy: return replace(self, **changes) @@ -66,26 +51,6 @@ class ChannelPolicy: return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()} -# SPEC §3.3. SALES и SUPPORT требуют продукта — иначе нарушают P1-P3. -_PRESETS = { - PolicyPreset.SALES: ChannelPolicy( - requires_authenticated_product_identity=False, - allow_anonymous_sessions=True, - allow_self_reported_contact=True, - allow_sales_attribution=True, - allow_checkout_actions=True, - ), - # Комбинация support-канала из SPEC-HUB-0010 §4.2. - PolicyPreset.SUPPORT: ChannelPolicy( - requires_authenticated_product_identity=True, - allow_anonymous_sessions=False, - allow_self_reported_contact=False, - allow_sales_attribution=False, - allow_checkout_actions=False, - ), -} - - @dataclass(frozen=True, slots=True) class PolicyViolation: rule: str @@ -96,58 +61,30 @@ class PolicyViolation: return {"rule": self.rule, "field": self.field, "detail": self.detail} -def policy_violations( - *, policy: ChannelPolicy, has_product: bool -) -> tuple[PolicyViolation, ...]: - """Нарушения P1-P5 для итогового состояния канала (SPEC §3.2).""" +def policy_violations(*, policy: ChannelPolicy) -> tuple[PolicyViolation, ...]: + """Нарушения P1-P2 для итогового состояния канала (SPEC §3.2).""" violations: list[PolicyViolation] = [] - if not has_product: - if policy.allow_checkout_actions: - violations.append( - PolicyViolation( - "P1", - "allowCheckoutActions", - "Коммерческие действия недоступны непродуктовому каналу", - ) + if policy.allow_checkout_actions: + violations.append( + PolicyViolation( + "P1", + "allowCheckoutActions", + "Коммерческие действия недоступны", ) - if policy.allow_sales_attribution: - violations.append( - PolicyViolation( - "P2", - "allowSalesAttribution", - "Attribution недоступна непродуктовому каналу", - ) - ) - if policy.requires_authenticated_product_identity: - violations.append( - PolicyViolation( - "P3", - "requiresAuthenticatedProductIdentity", - "Продуктовая идентичность требует продукта", - ) - ) - if policy.requires_authenticated_product_identity: - if policy.allow_anonymous_sessions: - violations.append( - PolicyViolation( - "P4", - "allowAnonymousSessions", - "Анонимные сессии несовместимы с обязательной идентичностью", - ) - ) - if policy.allow_self_reported_contact: - violations.append( - PolicyViolation( - "P5", - "allowSelfReportedContact", - "Самозаявленный контакт несовместим с обязательной идентичностью", - ) + ) + if policy.allow_sales_attribution: + violations.append( + PolicyViolation( + "P2", + "allowSalesAttribution", + "Attribution недоступна", ) + ) return tuple(violations) -def require_valid_policy(*, policy: ChannelPolicy, has_product: bool) -> None: - violations = policy_violations(policy=policy, has_product=has_product) +def require_valid_policy(*, policy: ChannelPolicy) -> None: + violations = policy_violations(policy=policy) if violations: raise PolicyInvariantError(violations) diff --git a/apps/backend/chatballs/channels/selectors.py b/apps/backend/chatballs/channels/selectors.py index 7232078..8db6d05 100644 --- a/apps/backend/chatballs/channels/selectors.py +++ b/apps/backend/chatballs/channels/selectors.py @@ -9,7 +9,7 @@ from chatballs.tenancy.context import TenantContext def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]: return queryset.select_related( - "product", "group", "ai_agent", "ai_agent__provider_integration" + "group", "ai_agent", "ai_agent__provider_integration" ).prefetch_related( Prefetch("connections", queryset=Integration.objects.order_by("id")) ).annotate( @@ -32,7 +32,7 @@ def channels_in_organization(context: TenantContext) -> QuerySet[Channel]: """ return ( Channel.objects.filter(organization_id=context.organization_id) - .select_related("product", "group", "ai_agent", "ai_agent__provider_integration") + .select_related("group", "ai_agent", "ai_agent__provider_integration") .order_by("name") ) diff --git a/apps/backend/chatballs/channels/services.py b/apps/backend/chatballs/channels/services.py index 7a735e5..91fd75d 100644 --- a/apps/backend/chatballs/channels/services.py +++ b/apps/backend/chatballs/channels/services.py @@ -13,7 +13,6 @@ from chatballs.channels.models import Channel from chatballs.channels.policy import ChannelPolicy, require_valid_policy from chatballs.identity.group_models import EmployeeGroup from chatballs.integrations.models import Integration, IntegrationKind -from chatballs.products.models import Product from chatballs.tenancy.context import TenantContext # SPEC §3.1: slug 1-64, входит в embed-URL web-виджета и после создания immutable. @@ -68,7 +67,6 @@ class ChannelUpdate: name: Any = UNSET group_id: Any = UNSET - product_id: Any = UNSET is_active: Any = UNSET policy: dict[str, bool] = field(default_factory=dict) @@ -96,19 +94,6 @@ def _group_for_channel( raise ValidationError({"groupId": "Unknown group"}) from error -def _product_for_channel( - *, context: TenantContext, product_id: int | None -) -> Product | None: - if product_id is None: - return None - try: - return Product.objects.get( - id=product_id, organization_id=context.organization_id - ) - except Product.DoesNotExist as error: - raise ValidationError({"productId": "Unknown product"}) from error - - @transaction.atomic def update_channel( *, context: TenantContext, channel: Channel, update: ChannelUpdate @@ -117,8 +102,7 @@ def update_channel( состояние, инварианты, конфликты, одна транзакция. Инварианты проверяются по итоговому состоянию, а не по переданным полям: - выключить продукт и коммерческие флаги можно одним запросом, а вот запрос, - оставляющий канал в запрещённой комбинации, отклоняется целиком. + запрос, оставляющий канал в запрещённой комбинации, отклоняется целиком. """ locked = Channel.objects.select_for_update().get( id=channel.id, organization_id=context.organization_id @@ -128,10 +112,6 @@ def update_channel( authorization.require_channel_manage(context) if update.group_id is not UNSET and update.group_id != locked.group_id: authorization.require_channel_manage(context) - if update.product_id is not UNSET and update.product_id != locked.product_id: - authorization.require_organization_manage( - context, operation="Изменение продукта канала" - ) if update.is_active is not UNSET and update.is_active != locked.is_active: authorization.require_organization_manage( context, operation="Изменение статуса канала" @@ -150,10 +130,6 @@ def update_channel( if update.group_id is not UNSET and update.group_id != locked.group_id: locked.group = _group_for_channel(context=context, group_id=update.group_id) changed.append("group") - if update.product_id is not UNSET and update.product_id != locked.product_id: - product = _product_for_channel(context=context, product_id=update.product_id) - locked.product = product - changed.append("product") if update.is_active is not UNSET and update.is_active != locked.is_active: locked.is_active = bool(update.is_active) changed.append("is_active") @@ -164,10 +140,7 @@ def update_channel( # Инварианты по целевому состоянию — до записи: нарушение отклоняет запрос # целиком, частичного применения не остаётся даже в памяти (§3.2, §6.5). - require_valid_policy( - policy=ChannelPolicy.from_channel(locked), - has_product=locked.product_id is not None, - ) + require_valid_policy(policy=ChannelPolicy.from_channel(locked)) if changed: locked.save(update_fields=[*changed, "updated_at"]) diff --git a/apps/backend/chatballs/channels/test_policy_migration.py b/apps/backend/chatballs/channels/test_policy_migration.py index 23a463e..1567b9a 100644 --- a/apps/backend/chatballs/channels/test_policy_migration.py +++ b/apps/backend/chatballs/channels/test_policy_migration.py @@ -1,188 +1,59 @@ -"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5. - - +"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P2. Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит - только Python-дефолты. Весь риск миграции сосредоточен в функции приведения - данных, поэтому она проверяется напрямую на реальном реестре моделей — это - честнее, чем поднимать историческое состояние, где `identity` откатывается - рассинхронно со схемой БД. - """ - - import importlib - - from django.apps import apps - from django.test import TestCase - - from chatballs.channels.models import Channel - from chatballs.identity.models import Organization -from chatballs.products.models import Product - - - migration = importlib.import_module( - "chatballs.channels.migrations.0005_enforce_policy_invariants" - ) - - - class PolicyInvariantMigrationTests(TestCase): - def setUp(self) -> None: - self.organization = Organization.objects.create(name="Acme", slug="acme") - self.product = Product.objects.create( - - organization=self.organization, code="app", name="Acme" - - ) - - - def _channel(self, code: str, **fields) -> Channel: - return Channel.objects.create( - organization=self.organization, code=code, name=code, **fields - ) - - def _run(self) -> None: - migration.relax_non_product_channels(apps, None) - - - def test_clears_commercial_flags_on_non_product_channels(self) -> None: - - # Ровно случай канала acme: непродуктовый, но с checkout и attribution. - + def test_clears_commercial_flags(self) -> None: + # Ровно случай канала acme: checkout и attribution включены. violating = self._channel( - "acme", allow_checkout_actions=True, allow_sales_attribution=True - ) - - self._run() - - violating.refresh_from_db() - self.assertFalse(violating.allow_checkout_actions) - self.assertFalse(violating.allow_sales_attribution) - - # Остальные флаги непродуктового канала не трогаются. - + # Остальные флаги канала не трогаются. self.assertTrue(violating.allow_anonymous_sessions) - self.assertTrue(violating.allow_self_reported_contact) - - - def test_leaves_product_channels_untouched(self) -> None: - - product_channel = self._channel( - - "app-sales", - - product=self.product, - - allow_checkout_actions=True, - - allow_sales_attribution=True, - - ) - - - - self._run() - - - - product_channel.refresh_from_db() - - self.assertTrue(product_channel.allow_checkout_actions) - - self.assertTrue(product_channel.allow_sales_attribution) - - - - def test_stops_the_rollout_on_p3_p5_violations(self) -> None: - - # Обязательная идентичность без продукта — смена смысла канала, - - # выбирать за владельца нельзя, поэтому выкат останавливается. - - self._channel("broken", requires_authenticated_product_identity=True) - - untouched = self._channel( - - "acme", allow_checkout_actions=True, allow_sales_attribution=True - - ) - - - - with self.assertRaises(RuntimeError) as error: - - self._run() - - - - self.assertIn("broken", str(error.exception)) - - untouched.refresh_from_db() - - self.assertTrue(untouched.allow_checkout_actions) - - - def test_is_idempotent(self) -> None: - self._channel("acme", allow_checkout_actions=True) - - self._run() - self._run() - - self.assertEqual( - - Channel.objects.filter( - - product__isnull=True, allow_checkout_actions=True - - ).count(), - + Channel.objects.filter(allow_checkout_actions=True).count(), 0, - ) - diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 94cd2ff..b09352d 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -53,7 +53,7 @@ def _mode(latest: Conversation) -> str: def clients_overview(organization_id: int) -> list[dict]: conversation_qs = Conversation.objects.select_related( - "channel", "channel__product", "connection", "assigned_operator" + "channel", "connection", "assigned_operator" ).order_by("-last_activity_at") identity_qs = ConnectionIdentity.objects.select_related("connection") contacts = Contact.objects.filter(organization_id=organization_id, merged_into__isnull=True).prefetch_related( @@ -69,15 +69,12 @@ def clients_overview(organization_id: int) -> list[dict]: if not conversations: continue # клиенты — те, кто писал channels: set[str] = set() - products: dict[str, str] = {} agents: dict[int, dict[str, object]] = {} open_dialogs = 0 for conversation in conversations: provider = conversation.connection.provider if conversation.connection_id else None if provider in PROVIDER_CODE: channels.add(PROVIDER_CODE[provider]) - if conversation.channel.product_id: - products[conversation.channel.product.code] = conversation.channel.product.name # Агент = карточка канала обработки: по нему фильтруется список (кадр K1). agents.setdefault( conversation.channel_id, @@ -104,7 +101,6 @@ def clients_overview(organization_id: int) -> list[dict]: # Первый непустой @логин среди identity каналов (остальные — в карточке). "username": next((identity.username for identity in contact.identities.all() if identity.username), ""), "channels": sorted(channels), - "products": [{"code": code, "name": name} for code, name in sorted(products.items())], "openDialogs": open_dialogs, "totalDialogs": len(conversations), "lastActivityAt": latest.last_activity_at.isoformat(), @@ -128,21 +124,18 @@ def client_detail(organization_id: int, contact_id: int) -> dict: contact = Contact.objects.get(organization_id=organization_id, id=contact_id) conversation_qs = Conversation.objects.filter( organization_id=organization_id, contact=contact - ).select_related("channel", "channel__product", "connection", "group", "assigned_operator", "note_author") + ).select_related("channel", "connection", "group", "assigned_operator", "note_author") conversations = list(conversation_qs.order_by("-last_activity_at")) if not conversations: raise Contact.DoesNotExist channels: set[str] = set() - products: dict[str, str] = {} open_dialogs = 0 dialogs: list[dict] = [] for conversation in conversations: provider = conversation.connection.provider if conversation.connection_id else None if provider in PROVIDER_CODE: channels.add(PROVIDER_CODE[provider]) - if conversation.channel.product_id: - products[conversation.channel.product.code] = conversation.channel.product.name if conversation.lifecycle == LifecycleState.OPEN: open_dialogs += 1 # Тема диалога — первое сообщение, превью — последнее (кадр K4). @@ -240,7 +233,6 @@ def client_detail(organization_id: int, contact_id: int) -> dict: "", ), "channels": sorted(channels), - "products": [{"code": code, "name": name} for code, name in sorted(products.items())], "openDialogs": open_dialogs, "totalDialogs": len(conversations), "firstContactAt": contact.created_at.isoformat(), diff --git a/apps/backend/chatballs/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py b/apps/backend/chatballs/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py index bdd5bbb..b50d524 100644 --- a/apps/backend/chatballs/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py +++ b/apps/backend/chatballs/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py @@ -12,16 +12,10 @@ class Migration(migrations.Migration): ('conversations', '0001_initial'), ('identity', '0006_alter_employeeprofile_totp_secret'), ('integrations', '0002_integration_channel_integration_poll_marker'), - ('support', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ - migrations.AddField( - model_name='conversation', - name='support_identity_snapshot', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='conversations', to='support.supportidentitysnapshot'), - ), migrations.AlterField( model_name='conversation', name='contact', @@ -29,6 +23,6 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='conversation', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('contact__isnull', True), ('support_identity_snapshot__isnull', False)), models.Q(('contact__isnull', False), ('support_identity_snapshot__isnull', True)), _connector='OR'), name='conversation_exactly_one_identity'), + constraint=models.CheckConstraint(condition=models.Q(('contact__isnull', False)), name='conversation_requires_contact'), ), ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index 24406ed..20da338 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -147,17 +147,10 @@ class Conversation(models.Model): organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="conversations") channel = models.ForeignKey("channels.Channel", on_delete=models.PROTECT, related_name="conversations") connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="conversations", null=True, blank=True) - # Источник identity диалога: sales Contact (лид/аноним) ИЛИ verified - # SupportIdentitySnapshot (authenticated клиент продукта). ADR-HUB-0022: - # sales-identity и support-identity разделены, ровно один источник на диалог. + # Единственный источник identity диалога — контакт. Авторизованный + # in-product клиент (SupportIdentitySnapshot) удалён вместе с сущностью + # Product (ADR-HUB-0045). contact = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="conversations", null=True, blank=True) - support_identity_snapshot = models.ForeignKey( - "support.SupportIdentitySnapshot", - on_delete=models.PROTECT, - related_name="conversations", - null=True, - blank=True, - ) # Внешний идентификатор чата (для отправки ответа в канал). external_chat_id = models.CharField(max_length=128, blank=True) # Транспортная мета диалога (ADR-HUB-0035): для email — тема исходного @@ -204,13 +197,10 @@ class Conversation(models.Model): ordering = ["-last_activity_at"] indexes = [models.Index(fields=["channel", "lifecycle"])] constraints = [ - # Ровно один источник identity: sales Contact XOR SupportIdentitySnapshot. + # Диалог всегда принадлежит контакту. models.CheckConstraint( - condition=( - models.Q(contact__isnull=True, support_identity_snapshot__isnull=False) - | models.Q(contact__isnull=False, support_identity_snapshot__isnull=True) - ), - name="conversation_exactly_one_identity", + condition=models.Q(contact__isnull=False), + name="conversation_requires_contact", ), ] diff --git a/apps/backend/chatballs/conversations/selectors.py b/apps/backend/chatballs/conversations/selectors.py index 406adc3..3e03bd5 100644 --- a/apps/backend/chatballs/conversations/selectors.py +++ b/apps/backend/chatballs/conversations/selectors.py @@ -10,11 +10,9 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]: Conversation.objects.filter(organization_id=context.organization_id) .select_related( "channel", - "channel__product", "contact", "connection", "assigned_operator", - "support_identity_snapshot", "group", ) .prefetch_related("labels") diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 3aea172..d718af0 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -81,27 +81,6 @@ def _history_item(conversation: Conversation) -> dict[str, object]: } -def _support_identity_snapshot( - conversation: Conversation, *, detailed: bool = False -) -> dict[str, object] | None: - snapshot = conversation.support_identity_snapshot - if snapshot is None: - return None - # Краткая карточка для списка диалогов; в detail-режиме — operator_context_json - # + account_key для правой панели оператора (ADR-HUB-0022 §8.3: рендер по контракту). - payload: dict[str, object] = { - "id": snapshot.id, - "subjectKey": snapshot.subject_key, - "displayName": snapshot.display_name, - "displayEmail": snapshot.display_email, - "contractCode": snapshot.contract_code, - } - if detailed: - payload["accountKey"] = snapshot.account_key - payload["operatorContextJson"] = snapshot.operator_context_json - return payload - - def _contact_username(conversation: Conversation) -> str: # Username живёт на identity подключения (у контакта их может быть несколько). # Только в detail-режиме — в списках это лишний запрос на каждый диалог. @@ -123,14 +102,8 @@ def _contact_email(conversation: Conversation) -> str: def _conversation_history(conversation: Conversation) -> list[Conversation]: - # История по тому же источнику identity: для sales — по contact, для - # support — по snapshot (ADR-HUB-0002: цепочка прошлых обращений). - if conversation.support_identity_snapshot_id: - qs = Conversation.objects.filter( - support_identity_snapshot_id=conversation.support_identity_snapshot_id - ) - else: - qs = Conversation.objects.filter(contact_id=conversation.contact_id) + # Цепочка прошлых обращений того же контакта (ADR-HUB-0002). + qs = Conversation.objects.filter(contact_id=conversation.contact_id) return list( qs.exclude(id=conversation.id) .select_related("channel", "connection", "assigned_operator") @@ -153,7 +126,6 @@ def conversation_payload( "id": channel.id, "code": channel.code, "name": channel.name, - "product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None, }, "connection": ( { @@ -166,8 +138,6 @@ def conversation_payload( if conversation.connection_id else None ), - # Источник identity: sales Contact ИЛИ verified SupportIdentitySnapshot. - # Для support-диалогов contact=None, клиент представлен snapshot'ом. "contact": ( { "id": conversation.contact_id, @@ -183,7 +153,6 @@ def conversation_payload( if conversation.contact_id else None ), - "supportIdentitySnapshot": _support_identity_snapshot(conversation, detailed=with_messages), "lifecycle": conversation.lifecycle, "controlMode": conversation.control_mode, "expectedResponder": conversation.expected_responder, diff --git a/apps/backend/chatballs/conversations/stats.py b/apps/backend/chatballs/conversations/stats.py index f6b27be..25fe5af 100644 --- a/apps/backend/chatballs/conversations/stats.py +++ b/apps/backend/chatballs/conversations/stats.py @@ -112,36 +112,21 @@ def sales_overview_stats(context, period: str) -> dict: period_by_channel = dict(period_qs.values_list("channel_id").annotate(c=Count("id"))) by_channel: list[dict] = [] - by_product: dict[str, dict] = {} channels = channels_in_organization(context) for channel in channels: open_count = open_by_channel.get(channel.id, 0) period_count = period_by_channel.get(channel.id, 0) by_channel.append({"code": channel.code, "name": channel.name, "openDialogs": open_count, "dialogs": period_count}) - if channel.product_id: - bucket = by_product.setdefault( - channel.product.code, - {"code": channel.product.code, "name": channel.product.name, "openDialogs": 0, "dialogs": 0}, - ) - bucket["openDialogs"] += open_count - bucket["dialogs"] += period_count problems = [] for conversation in ( open_qs.filter(expected_responder=ExpectedResponder.OPERATOR) - .select_related("contact", "support_identity_snapshot", "channel", "channel__product") + .select_related("contact", "channel") .order_by("last_activity_at")[:5] ): minutes = int((now - conversation.last_activity_at).total_seconds() // 60) meta = conversation.channel.name - # Имя клиента: sales Contact.name либо display_name support-снапшота. - snapshot = conversation.support_identity_snapshot - if conversation.contact_id: - title = conversation.contact.name or "Гость" - elif snapshot is not None: - title = snapshot.display_name or f"client:{snapshot.subject_key[:8]}" - else: - title = "Гость" + title = (conversation.contact.name if conversation.contact_id else "") or "Гость" problems.append( { "conversationId": conversation.id, @@ -156,7 +141,6 @@ def sales_overview_stats(context, period: str) -> dict: "ops": ops, "period": period_block, "byChannel": by_channel, - "byProduct": list(by_product.values()), "chart": _chart(organization_id, period, start, now), "problems": problems, } diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index 685cf1c..32b82a8 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -79,7 +79,6 @@ class ConversationListView(ConversationViewBase): ) items = items.filter( Q(contact__name__icontains=query) - | Q(support_identity_snapshot__display_name__icontains=query) | Q(Exists(message_match)) ) items = list(items) diff --git a/apps/backend/chatballs/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py index b9c762c..29e90c5 100644 --- a/apps/backend/chatballs/identity/administration_payloads.py +++ b/apps/backend/chatballs/identity/administration_payloads.py @@ -21,10 +21,6 @@ AUDIT_ACTION_LABELS = { "administration.organization_updated": "Изменены данные организации", "administration.logo_updated": "Изменён логотип организации", "administration.logo_deleted": "Удалён логотип организации", - "products.product_created": "Добавлен продукт", - "products.product_updated": "Изменён продукт", - "products.product_active": "Продукт включён", - "products.product_disabled": "Продукт отключён", "integrations.integration_created": "Добавлена интеграция", "integrations.integration_updated": "Изменена интеграция", "integrations.integration_deleted": "Удалена интеграция", diff --git a/apps/backend/chatballs/identity/bootstrap.py b/apps/backend/chatballs/identity/bootstrap.py index 375053f..4122b91 100644 --- a/apps/backend/chatballs/identity/bootstrap.py +++ b/apps/backend/chatballs/identity/bootstrap.py @@ -10,7 +10,6 @@ from chatballs.identity.models import ( Organization, OrganizationMembership, ) -from chatballs.products.models import Product @dataclass(frozen=True) @@ -43,8 +42,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst support_group, _ = EmployeeGroup.objects.get_or_create( organization=organization, name="Поддержка" ) - for code, name in (("site", "Сайт"), ("app", "Приложение")): - Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name}) # Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов. owner, created_owner = HumanUser.objects.get_or_create( diff --git a/apps/backend/chatballs/identity/capabilities.py b/apps/backend/chatballs/identity/capabilities.py index 0521744..444eef0 100644 --- a/apps/backend/chatballs/identity/capabilities.py +++ b/apps/backend/chatballs/identity/capabilities.py @@ -12,8 +12,6 @@ ALL_CAPABILITIES: frozenset[str] = frozenset( "employees.view", "employees.manage", "groups.manage", - "products.view", - "products.manage", "channels.view", "channels.manage", "ai.view", diff --git a/apps/backend/chatballs/identity/demo_seed/data/catalog.json b/apps/backend/chatballs/identity/demo_seed/data/catalog.json deleted file mode 100644 index a4ee2ff..0000000 --- a/apps/backend/chatballs/identity/demo_seed/data/catalog.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schemaVersion": 1, - "_comment": "Product — скрытая техническая ось (ADR-HUB-0041): нужен порталу поддержки и веб-виджету.", - "products": [ - {"code": "atelie-nord-site", "name": "Сайт ателье", "siteUrl": "https://atelie-nord.ru"} - ] -} diff --git a/apps/backend/chatballs/identity/demo_seed/data/channels_ai.json b/apps/backend/chatballs/identity/demo_seed/data/channels_ai.json index b41670a..a4491aa 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/channels_ai.json +++ b/apps/backend/chatballs/identity/demo_seed/data/channels_ai.json @@ -56,7 +56,6 @@ "limits": {}, "createdDaysAgo": 30, "policy": { - "requires_authenticated_product_identity": false, "allow_anonymous_sessions": true, "allow_self_reported_contact": true } @@ -67,9 +66,9 @@ "name": "Личный кабинет", "group": "support", "aiStatus": "ACTIVE", - "persona": "Ты — помощник личного кабинета ателье «Норд»: заказы, оплата, статусы, доставка, возвраты. Клиент авторизован — его заказы известны.", + "persona": "Ты — помощник раздела «Личный кабинет» ателье «Норд»: заказы, оплата, статусы, доставка, возвраты.", "tone": "Спокойно и по делу.", - "instructions": "Опирайся на данные заказа клиента и знания о кабинете. Жалобы на оплату — передай сотруднику.", + "instructions": "Опирайся на знания о кабинете и заказах. Номер заказа спрашивай, если его не назвали. Жалобы на оплату — передай сотруднику.", "knowledge": [ "account", "delivery", @@ -82,11 +81,9 @@ "limits": {}, "createdDaysAgo": 28, "policy": { - "requires_authenticated_product_identity": true, - "allow_anonymous_sessions": false, - "allow_self_reported_contact": false - }, - "product": "atelie-nord-site" + "allow_anonymous_sessions": true, + "allow_self_reported_contact": true + } }, { "key": "reception", diff --git a/apps/backend/chatballs/identity/demo_seed/data/conversations.json b/apps/backend/chatballs/identity/demo_seed/data/conversations.json index 9927854..0b5f8cd 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/conversations.json +++ b/apps/backend/chatballs/identity/demo_seed/data/conversations.json @@ -866,7 +866,6 @@ "connection": "web_account", "contact": "sergey", "identity": "sergey_web", - "supportIdentitySnapshot": "sergey_account", "externalChatId": "portal-sergey-1", "lifecycle": "CLOSED", "controlMode": "HUMAN", diff --git a/apps/backend/chatballs/identity/demo_seed/data/support.json b/apps/backend/chatballs/identity/demo_seed/data/support.json index 10ede0c..2bc8119 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/support.json +++ b/apps/backend/chatballs/identity/demo_seed/data/support.json @@ -1,55 +1,11 @@ { "schemaVersion": 1, - "contracts": [ - { - "key": "site_contract", - "code": "SITE-2026", - "product": "atelie-nord-site", - "version": 1, - "status": "ACTIVE" - } - ], - "identitySnapshots": [ - { - "key": "sergey_account", - "contract": "site_contract", - "subjectKey": "user:4402", - "accountKey": "acc-4402", - "displayName": "Сергей Крылов", - "displayEmail": "s.krylov@example.ru", - "payload": { - "orders": [ - { - "number": "4402", - "status": "В производстве", - "total": 12480 - } - ], - "city": "Казань" - }, - "operatorContext": { - "Клиент с": "апрель 2026", - "Заказов": "2", - "Последний заказ": "4402 — комплект штор, 12 480 ₽" - }, - "aiContext": { - "orders": "4402 in production" - }, - "searchText": "Сергей Крылов s.krylov@example.ru 4402 Казань", - "issuedDaysAgo": 6, - "expiresInDays": 24 - } - ], "portal": { "slug": "atelie-nord", "name": "Помощь · Ателье Норд", "locale": "ru", "status": "PUBLISHED", "widgetConnection": "web_help", - "accountWidgetConnection": "web_account", - "products": [ - "atelie-nord-site" - ], "categories": [ { "slug": "zakaz", @@ -87,7 +43,9 @@ "helpfulVotes": 14, "unhelpfulVotes": 1, "publishedDaysAgo": 27, - "files": ["katalog-tkanej-2026.pdf"] + "files": [ + "katalog-tkanej-2026.pdf" + ] }, { "slug": "sroki-dostavki", diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/catalog.py b/apps/backend/chatballs/identity/demo_seed/loaders/catalog.py deleted file mode 100644 index 75f68c8..0000000 --- a/apps/backend/chatballs/identity/demo_seed/loaders/catalog.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Каталог: продукты (скрытая техническая привязка, ADR-HUB-0041).""" - -from __future__ import annotations - -from chatballs.identity.demo_seed import manifest -from chatballs.identity.demo_seed.refs import DemoRefs -from chatballs.products.models import Product, ProductStatus -from chatballs.tenancy.context import TenantContext - - -def load(context: TenantContext, refs: DemoRefs) -> None: - data = manifest.load("catalog") - organization = refs.organization - - for item in data["products"]: - product, _ = Product.objects.get_or_create( - organization=organization, - code=item["code"], - defaults={ - "name": item["name"], - "status": ProductStatus.ACTIVE, - "site_url": item.get("siteUrl", ""), - }, - ) - refs.products[item["code"]] = product diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py index d6eaabb..1b0c077 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py @@ -72,7 +72,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ defaults={ "name": item["name"], "group": refs.groups.get(item.get("group")), - "product": refs.products.get(item.get("product")), "is_active": item.get("isActive", True), **item.get("policy", {}), }, @@ -250,7 +249,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None: failures_left -= 1 invocation = LlmInvocation.objects.create( channel=channel, - product=channel.product, purpose="agent_chat", operation="chat", model=spec["model"], @@ -269,7 +267,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None: tokens = random.randint(200, 900) invocation = LlmInvocation.objects.create( channel=channel, - product=None, purpose="knowledge_index", operation="embedding", model="text-embedding-3-small", diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py index 58cf678..6e668f1 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py @@ -164,14 +164,11 @@ def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, cur refs.conversations[item["key"]] = conversation return - snapshot = refs.identity_snapshots.get(item.get("supportIdentitySnapshot")) conversation = Conversation.objects.create( organization=organization, channel=channel, connection=connection, - # Ровно одна личность: снимок из личного кабинета либо контакт. - contact=None if snapshot is not None else contact, - support_identity_snapshot=snapshot, + contact=contact, external_chat_id=external_chat_id, transport_meta=item.get("transportMeta", {}), lifecycle=item.get("lifecycle", "OPEN"), diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/support.py b/apps/backend/chatballs/identity/demo_seed/loaders/support.py index aee6dd9..29e44d3 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/support.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/support.py @@ -1,5 +1,5 @@ -"""Поддержка: контракты, снимки личности, портал помощи, статьи (опубликованные, -черновик, архив, вторая ревизия), оценки читателей, статьи у агентов.""" +"""Поддержка: портал помощи, статьи (опубликованные, черновик, архив, вторая +ревизия), оценки читателей, статьи у агентов.""" from __future__ import annotations @@ -10,11 +10,6 @@ from django.core.files.base import ContentFile from chatballs.identity.demo_seed import manifest from chatballs.identity.demo_seed.loaders.common import backdate, now from chatballs.identity.demo_seed.refs import DemoRefs -from chatballs.support.models import ( - ContractStatus, - ProductSupportContract, - SupportIdentitySnapshot, -) from chatballs.support_portals.content_services import ( add_revision, archive_article, @@ -31,7 +26,6 @@ from chatballs.support_portals.models import ( from chatballs.support_portals.portal_services import ( PortalInput, create_portal, - replace_product_links, set_portal_status, ) from chatballs.support_portals.statuses import ArticleStatus, PortalStatus @@ -42,53 +36,10 @@ from chatballs.tenancy.storage import adjust_storage_usage def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("support") current = now() - _ensure_contracts(refs, data.get("contracts", [])) - _ensure_snapshots(refs, data.get("identitySnapshots", []), current) _ensure_portal(context, refs, data.get("portal"), current) _link_agent_articles(refs) -def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None: - for item in items: - contract, _ = ProductSupportContract.objects.get_or_create( - organization=refs.organization, - code=item["code"], - defaults={ - "product": refs.products[item["product"]], - "version": item["version"], - "status": item.get("status", ContractStatus.ACTIVE), - }, - ) - support_channel = refs.channels.get("support") - if support_channel is not None: - contract.allowed_channels.add(support_channel) - refs.support_contracts[item["key"]] = contract - - -def _ensure_snapshots(refs: DemoRefs, items: list[dict], current) -> None: - for item in items: - contract = refs.support_contracts[item["contract"]] - snapshot, _ = SupportIdentitySnapshot.objects.get_or_create( - organization=refs.organization, - contract=contract, - subject_key=item["subjectKey"], - defaults={ - "product": contract.product, - "contract_code": contract.code, - "account_key": item.get("accountKey"), - "display_name": item.get("displayName", ""), - "display_email": item.get("displayEmail", ""), - "payload_json": item.get("payload", {}), - "operator_context_json": item.get("operatorContext", {}), - "ai_context_json": item.get("aiContext", {}), - "search_text": item.get("searchText", ""), - "token_issued_at": current - timedelta(days=item.get("issuedDaysAgo", 1)), - "token_expires_at": current + timedelta(days=item.get("expiresInDays", 30)), - }, - ) - refs.identity_snapshots[item["key"]] = snapshot - - def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None, current) -> None: if not portal_data: return @@ -105,20 +56,6 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N ), ) backdate(portal, current - timedelta(days=28), "created_at") - # Виджет портала — анонимный (чат на странице помощи); виджет продукта — - # авторизованный (личный кабинет), через него идут обращения с личностью. - account_widget = refs.widgets.get(portal_data.get("accountWidgetConnection")) - links = [] - for product_code in portal_data.get("products", []): - product = refs.products[product_code] - links.append( - { - "productId": product.id, - "supportWidgetId": account_widget.id if account_widget is not None else None, - } - ) - if links: - replace_product_links(context=context, portal=portal, links=links) categories: dict[str, PortalCategory] = {} for item in portal_data.get("categories", []): diff --git a/apps/backend/chatballs/identity/demo_seed/manifest.py b/apps/backend/chatballs/identity/demo_seed/manifest.py index 9f56124..4c61467 100644 --- a/apps/backend/chatballs/identity/demo_seed/manifest.py +++ b/apps/backend/chatballs/identity/demo_seed/manifest.py @@ -21,7 +21,6 @@ SCHEMA_VERSION = 1 #: Файлы манифестов по доменам (имя файла без расширения). MANIFEST_FILES: tuple[str, ...] = ( "organization", - "catalog", "channels_ai", "conversations", "support", diff --git a/apps/backend/chatballs/identity/demo_seed/orchestrator.py b/apps/backend/chatballs/identity/demo_seed/orchestrator.py index f881432..71a8185 100644 --- a/apps/backend/chatballs/identity/demo_seed/orchestrator.py +++ b/apps/backend/chatballs/identity/demo_seed/orchestrator.py @@ -2,15 +2,12 @@ Создаёт полный, взаимосвязанный набор выдуманных данных одной организации, будто система уже работает. Запускается строго в порядке FK-зависимостей: -foundation → catalog → channels/ai → support → conversations → operations. +foundation → channels/ai → support → conversations → operations. Идемпотентен на каждом шаге. """ from __future__ import annotations -from chatballs.identity.demo_seed.loaders import ( - catalog as catalog_loader, -) from chatballs.identity.demo_seed.loaders import ( channels_ai as channels_ai_loader, ) @@ -47,10 +44,8 @@ def run_demo_seed( refs.recorder = recorder foundation_loader.load(context, refs) - catalog_loader.load(context, refs) channels_ai_loader.load(context, refs) - # Поддержка раньше диалогов: диалог портала ссылается на снимок личности, - # а статьи портала привязываются к агентам. + # Поддержка раньше диалогов: статьи портала привязываются к агентам. support_loader.load(context, refs) conversations_loader.load(context, refs) operations_loader.load(context, refs) diff --git a/apps/backend/chatballs/identity/demo_seed/refs.py b/apps/backend/chatballs/identity/demo_seed/refs.py index ff4a50f..f190e3d 100644 --- a/apps/backend/chatballs/identity/demo_seed/refs.py +++ b/apps/backend/chatballs/identity/demo_seed/refs.py @@ -22,7 +22,6 @@ class DemoRefs: groups: dict[str, object] = field(default_factory=dict) memberships: dict[str, object] = field(default_factory=dict) users: dict[str, object] = field(default_factory=dict) - products: dict[str, object] = field(default_factory=dict) integrations: dict[str, object] = field(default_factory=dict) channels: dict[str, object] = field(default_factory=dict) agents: dict[str, object] = field(default_factory=dict) @@ -35,7 +34,5 @@ class DemoRefs: contacts: dict[str, object] = field(default_factory=dict) identities: dict[str, object] = field(default_factory=dict) conversations: dict[str, object] = field(default_factory=dict) - support_contracts: dict[str, object] = field(default_factory=dict) - identity_snapshots: dict[str, object] = field(default_factory=dict) calls: dict[str, object] = field(default_factory=dict) portal: object | None = None diff --git a/apps/backend/chatballs/identity/migrations/0001_initial.py b/apps/backend/chatballs/identity/migrations/0001_initial.py index 4f3fba4..d8d3680 100644 --- a/apps/backend/chatballs/identity/migrations/0001_initial.py +++ b/apps/backend/chatballs/identity/migrations/0001_initial.py @@ -135,28 +135,6 @@ class Migration(migrations.Migration): ), ], ), - migrations.CreateModel( - name="Product", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("code", models.SlugField(max_length=64)), - ("name", models.CharField(max_length=255)), - ( - "status", - models.CharField(choices=[("ACTIVE", "Active"), ("DISABLED", "Disabled")], default="ACTIVE", max_length=32), - ), - ("site_url", models.URLField(blank=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "organization", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="products", - to="identity.organization", - ), - ), - ], - ), migrations.CreateModel( name="EmployeeProfile", fields=[ @@ -211,8 +189,4 @@ class Migration(migrations.Migration): model_name="department", constraint=models.UniqueConstraint(fields=("organization", "code"), name="uniq_department_org_code"), ), - migrations.AddConstraint( - model_name="product", - constraint=models.UniqueConstraint(fields=("organization", "code"), name="uniq_product_org_code"), - ), ] diff --git a/apps/backend/chatballs/identity/migrations/0005_move_product_state.py b/apps/backend/chatballs/identity/migrations/0005_move_product_state.py index c4b6e78..e73b30f 100644 --- a/apps/backend/chatballs/identity/migrations/0005_move_product_state.py +++ b/apps/backend/chatballs/identity/migrations/0005_move_product_state.py @@ -1,15 +1,11 @@ +# ADR-HUB-0045: сущность Product удалена целиком. Миграция сохранена пустой — +# на неё ссылаются следующие миграции identity. from django.db import migrations class Migration(migrations.Migration): dependencies = [ ("identity", "0004_employeeprofile_phone"), - ("products", "0001_move_product_state"), ] - operations = [ - migrations.SeparateDatabaseAndState( - database_operations=[], - state_operations=[migrations.DeleteModel(name="Product")], - ) - ] + operations = [] diff --git a/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py b/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py index d9f3ae9..1deb3eb 100644 --- a/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py +++ b/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py @@ -34,7 +34,6 @@ class Migration(migrations.Migration): ('channels', '0006_remove_channel_department'), ('identity', '0019_drop_sales_capabilities'), ('notifications', '0008_remove_notification_department'), - ('products', '0012_delete_productdepartment'), ('support_portals', '0008_remove_supportportal_department'), ] diff --git a/apps/backend/chatballs/identity/test_administration_api.py b/apps/backend/chatballs/identity/test_administration_api.py index 26a4a31..0699a73 100644 --- a/apps/backend/chatballs/identity/test_administration_api.py +++ b/apps/backend/chatballs/identity/test_administration_api.py @@ -181,9 +181,9 @@ class AdministrationApiTests(TestCase): self.assertEqual(response.status_code, 400) self.assertIn("PNG, JPEG и WebP", response.json()["detail"]) - def test_audit_returns_product_text_instead_of_internal_action_codes(self) -> None: + def test_audit_returns_readable_text_instead_of_internal_action_codes(self) -> None: record_audit_event( - action="products.product_created", + action="administration.organization_updated", actor=self.owner, organization=self.organization, ) @@ -192,8 +192,8 @@ class AdministrationApiTests(TestCase): self.assertEqual(response.status_code, 200) event = response.json()["items"][0] - self.assertEqual(event["action"], "Добавлен продукт") - self.assertNotIn("products.", event["action"]) + self.assertEqual(event["action"], "Изменены данные организации") + self.assertNotIn("administration.", event["action"]) class InstanceAddressTests(TestCase): diff --git a/apps/backend/chatballs/identity/tests.py b/apps/backend/chatballs/identity/tests.py index 5d100c3..5d8376a 100644 --- a/apps/backend/chatballs/identity/tests.py +++ b/apps/backend/chatballs/identity/tests.py @@ -22,7 +22,6 @@ from chatballs.identity.models import ( OrganizationMembership, ) from chatballs.identity.policy import ResourceScope, authorize -from chatballs.products.models import Product _LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} @@ -43,7 +42,6 @@ class BootstrapOwnerTests(TestCase): {"Операторы", "Поддержка"}, ) self.assertEqual(result.support_group.name, "Поддержка") - self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"site", "app"}) self.assertEqual(result.owner.memberships.get().role, EmployeeRole.OWNER) # TOTP выключен по умолчанию (намеренно, локальная разработка). self.assertFalse(result.owner.memberships.get().totp_required) @@ -67,7 +65,6 @@ class BootstrapOwnerTests(TestCase): self.assertFalse(second.created_owner) self.assertEqual(HumanUser.objects.count(), 2) self.assertEqual(Organization.objects.count(), 1) - self.assertEqual(Product.objects.count(), 2) def test_bootstrap_promotes_existing_owner_to_django_admin_access(self) -> None: user = HumanUser.objects.create_user(email="owner@example.com", password="temporary-password") @@ -595,55 +592,14 @@ class CompanyEndpointTests(TestCase): self.client = APIClient() self.client.login(username="owner@example.com", password="temporary-password") - def test_owner_reads_groups_and_products(self) -> None: + def test_owner_reads_groups(self) -> None: groups_response = self.client.get("/api/v1/company/groups/") - products_response = self.client.get("/api/v1/company/products/") self.assertEqual(groups_response.status_code, 200) - self.assertEqual(products_response.status_code, 200) self.assertEqual( set(group["name"] for group in groups_response.json()["items"]), {"Операторы", "Поддержка"}, ) - self.assertEqual( - set(product["code"] for product in products_response.json()["items"]), - {"site", "app"}, - ) - - def test_owner_creates_and_deactivates_product(self) -> None: - create_response = self.client.post( - "/api/v1/company/products/create/", - data=json.dumps({"code": "academy", "name": "Academy"}), - content_type="application/json", - ) - - self.assertEqual(create_response.status_code, 201) - product_id = create_response.json()["product"]["id"] - - deactivate_response = self.client.post(f"/api/v1/company/products/{product_id}/deactivate/") - - self.assertEqual(deactivate_response.status_code, 200) - self.assertEqual(deactivate_response.json()["product"]["status"], "DISABLED") - self.assertTrue(AuditEvent.objects.filter(action="products.product_created").exists()) - self.assertTrue(AuditEvent.objects.filter(action="products.product_disabled").exists()) - - def test_operator_cannot_create_product(self) -> None: - operator = HumanUser.objects.create_user(email="operator@example.com", password="operator-password") - OrganizationMembership.objects.create( - user=operator, - organization=self.organization, - role=EmployeeRole.EMPLOYEE, - ) - self.client.logout() - self.client.login(username="operator@example.com", password="operator-password") - - response = self.client.post( - "/api/v1/company/products/create/", - data=json.dumps({"code": "academy", "name": "Academy"}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 403) class TotpSecretEncryptionTests(TestCase): @@ -945,12 +901,6 @@ class EmployeeGovernanceTests(TestCase): client = self._client("admin@example.com") audit = client.get("/api/v1/company/administration/audit/") self.assertEqual(audit.status_code, 200) - product = client.post( - "/api/v1/company/products/create/", - data=json.dumps({"code": "academy", "name": "Academy"}), - content_type="application/json", - ) - self.assertEqual(product.status_code, 201) def test_employee_denied_audit(self) -> None: self._make("emp@example.com", EmployeeRole.EMPLOYEE) diff --git a/apps/backend/chatballs/products/__init__.py b/apps/backend/chatballs/products/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/apps/backend/chatballs/products/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/backend/chatballs/products/admin.py b/apps/backend/chatballs/products/admin.py deleted file mode 100644 index 8a8e63e..0000000 --- a/apps/backend/chatballs/products/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.contrib import admin - -from chatballs.products.models import Product - - -@admin.register(Product) -class ProductAdmin(admin.ModelAdmin): - list_display = ["code", "name", "organization", "status", "site_url"] - list_filter = ["organization", "status"] - search_fields = ["code", "name"] diff --git a/apps/backend/chatballs/products/apps.py b/apps/backend/chatballs/products/apps.py deleted file mode 100644 index f926856..0000000 --- a/apps/backend/chatballs/products/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class ProductsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "chatballs.products" - diff --git a/apps/backend/chatballs/products/migrations/0001_move_product_state.py b/apps/backend/chatballs/products/migrations/0001_move_product_state.py deleted file mode 100644 index 982b613..0000000 --- a/apps/backend/chatballs/products/migrations/0001_move_product_state.py +++ /dev/null @@ -1,54 +0,0 @@ -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [("identity", "0004_employeeprofile_phone")] - - operations = [ - migrations.SeparateDatabaseAndState( - database_operations=[], - state_operations=[ - migrations.CreateModel( - name="Product", - fields=[ - ( - "id", - models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), - ), - ("code", models.SlugField(max_length=64)), - ("name", models.CharField(max_length=255)), - ( - "status", - models.CharField( - choices=[("ACTIVE", "Активен"), ("DISABLED", "Неактивен")], - default="ACTIVE", - max_length=32, - ), - ), - ("site_url", models.URLField(blank=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "organization", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="products", - to="identity.organization", - ), - ), - ], - options={ - "db_table": "identity_product", - "constraints": [ - models.UniqueConstraint( - fields=("organization", "code"), - name="uniq_product_org_code", - ) - ], - }, - ) - ], - ) - ] diff --git a/apps/backend/chatballs/products/migrations/0002_product_sales_description_product_summary_and_more.py b/apps/backend/chatballs/products/migrations/0002_product_sales_description_product_summary_and_more.py deleted file mode 100644 index 0c90181..0000000 --- a/apps/backend/chatballs/products/migrations/0002_product_sales_description_product_summary_and_more.py +++ /dev/null @@ -1,118 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-19 17:51 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('identity', '0005_move_product_state'), - ('products', '0001_move_product_state'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='sales_description', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='product', - name='summary', - field=models.CharField(blank=True, max_length=500), - ), - migrations.AddField( - model_name='product', - name='updated_at', - field=models.DateTimeField(auto_now=True), - ), - migrations.CreateModel( - name='Offer', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.SlugField(max_length=64)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('fulfillment_type', models.CharField(choices=[('SAAS_ACCESS', 'SaaS-доступ'), ('BOX_LICENSE', 'Коробочная лицензия'), ('SUPPORT_EXTENSION', 'Продление поддержки')], max_length=32)), - ('payment_type', models.CharField(choices=[('ONE_TIME', 'Разовая оплата'), ('SUBSCRIPTION', 'Подписка')], max_length=32)), - ('access_schema', models.JSONField(blank=True, default=dict)), - ('fiscal_name', models.CharField(max_length=255)), - ('fiscal_attributes', models.JSONField(blank=True, default=dict)), - ('subscription_rules', models.JSONField(blank=True, default=dict)), - ('is_active', models.BooleanField(default=True)), - ('ai_offerable', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('primary_box_offer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='support_extensions', to='products.offer')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='offers', to='products.product')), - ], - ), - migrations.CreateModel( - name='Price', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('version', models.PositiveIntegerField()), - ('amount_minor', models.PositiveBigIntegerField()), - ('currency', models.CharField(default='RUB', max_length=3)), - ('billing_period', models.CharField(choices=[('ONE_TIME', 'Разово'), ('MONTH', 'Месяц'), ('YEAR', 'Год')], max_length=16)), - ('valid_from', models.DateTimeField()), - ('valid_until', models.DateTimeField(blank=True, null=True)), - ('is_active', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('offer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prices', to='products.offer')), - ], - options={ - 'ordering': ['offer_id', 'billing_period', '-version'], - }, - ), - migrations.CreateModel( - name='MarketplacePublication', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('marketplace_code', models.SlugField(max_length=64)), - ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликовано'), ('DISABLED', 'Отключено')], default='DRAFT', max_length=16)), - ('external_reference', models.CharField(blank=True, max_length=255)), - ('published_at', models.DateTimeField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('price', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='marketplace_publications', to='products.price')), - ], - ), - migrations.CreateModel( - name='ProductDepartment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='product_links', to='identity.department')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='department_links', to='products.product')), - ], - ), - migrations.AddConstraint( - model_name='offer', - constraint=models.UniqueConstraint(fields=('product', 'code'), name='uniq_offer_product_code'), - ), - migrations.AddConstraint( - model_name='offer', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('fulfillment_type', 'SUPPORT_EXTENSION'), ('primary_box_offer__isnull', False)), models.Q(('fulfillment_type', 'SUPPORT_EXTENSION'), _negated=True), _connector='OR'), name='support_offer_requires_primary_box'), - ), - migrations.AddConstraint( - model_name='price', - constraint=models.UniqueConstraint(fields=('offer', 'version'), name='uniq_price_offer_version'), - ), - migrations.AddConstraint( - model_name='price', - constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('offer', 'currency', 'billing_period'), name='uniq_active_price_offer_currency_period'), - ), - migrations.AddConstraint( - model_name='price', - constraint=models.CheckConstraint(condition=models.Q(('amount_minor__gt', 0)), name='price_amount_positive'), - ), - migrations.AddConstraint( - model_name='marketplacepublication', - constraint=models.UniqueConstraint(fields=('marketplace_code', 'price'), name='uniq_marketplace_price_publication'), - ), - migrations.AddConstraint( - model_name='productdepartment', - constraint=models.UniqueConstraint(fields=('product', 'department'), name='uniq_product_department'), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0003_assign_existing_products_to_sales.py b/apps/backend/chatballs/products/migrations/0003_assign_existing_products_to_sales.py deleted file mode 100644 index 7a04427..0000000 --- a/apps/backend/chatballs/products/migrations/0003_assign_existing_products_to_sales.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db import migrations - - -def assign_products_to_sales(apps, schema_editor): - Product = apps.get_model("products", "Product") - ProductDepartment = apps.get_model("products", "ProductDepartment") - Department = apps.get_model("identity", "Department") - for product in Product.objects.all(): - sales = Department.objects.filter(organization_id=product.organization_id, code="sales").first() - if sales is not None: - ProductDepartment.objects.get_or_create(product_id=product.id, department_id=sales.id) - - -class Migration(migrations.Migration): - dependencies = [("products", "0002_product_sales_description_product_summary_and_more")] - - operations = [migrations.RunPython(assign_products_to_sales, migrations.RunPython.noop)] diff --git a/apps/backend/chatballs/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py b/apps/backend/chatballs/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py deleted file mode 100644 index b500257..0000000 --- a/apps/backend/chatballs/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-19 18:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0003_assign_existing_products_to_sales'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='price', - name='uniq_price_offer_version', - ), - migrations.AddConstraint( - model_name='price', - constraint=models.UniqueConstraint(fields=('offer', 'currency', 'billing_period', 'version'), name='uniq_price_offer_period_version'), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0005_remove_price_price_amount_positive_and_more.py b/apps/backend/chatballs/products/migrations/0005_remove_price_price_amount_positive_and_more.py deleted file mode 100644 index e73c32f..0000000 --- a/apps/backend/chatballs/products/migrations/0005_remove_price_price_amount_positive_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-26 11:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0004_remove_price_uniq_price_offer_version_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='price', - name='price_amount_positive', - ), - migrations.AlterField( - model_name='offer', - name='fiscal_name', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddConstraint( - model_name='price', - constraint=models.CheckConstraint(condition=models.Q(('amount_minor__gte', 0)), name='price_amount_non_negative'), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0006_product_ingest_token_hash.py b/apps/backend/chatballs/products/migrations/0006_product_ingest_token_hash.py deleted file mode 100644 index c271ca2..0000000 --- a/apps/backend/chatballs/products/migrations/0006_product_ingest_token_hash.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-29 20:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0005_remove_price_price_amount_positive_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='ingest_token_hash', - field=models.CharField(blank=True, db_index=True, max_length=64), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py b/apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py deleted file mode 100644 index 161ac54..0000000 --- a/apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.15 on 2026-07-08 21:29 - -import chatballs.identity.crypto -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('products', '0006_product_ingest_token_hash'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='support_token_secret', - field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=512), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0008_remove_product_knowledge_fields.py b/apps/backend/chatballs/products/migrations/0008_remove_product_knowledge_fields.py deleted file mode 100644 index 5508036..0000000 --- a/apps/backend/chatballs/products/migrations/0008_remove_product_knowledge_fields.py +++ /dev/null @@ -1,16 +0,0 @@ -# ADR-HUB-0023: содержательное описание продукта живёт в Знаниях; продукт — -# техническая запись-якорь (токены, офферы, каналы). - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("products", "0007_product_support_token_secret"), - ] - - operations = [ - migrations.RemoveField(model_name="product", name="summary"), - migrations.RemoveField(model_name="product", name="sales_description"), - ] diff --git a/apps/backend/chatballs/products/migrations/0009_marketplacepublication_organization_and_more.py b/apps/backend/chatballs/products/migrations/0009_marketplacepublication_organization_and_more.py deleted file mode 100644 index c4e7ce3..0000000 --- a/apps/backend/chatballs/products/migrations/0009_marketplacepublication_organization_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.16 on 2026-07-15 00:42 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('identity', '0013_accessprofilecapability_organization_and_more'), - ('products', '0008_remove_product_knowledge_fields'), - ] - - operations = [ - migrations.AddField( - model_name='marketplacepublication', - name='organization', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.AddField( - model_name='offer', - name='organization', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.AddField( - model_name='price', - name='organization', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.AddField( - model_name='productdepartment', - name='organization', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0010_alter_marketplacepublication_organization_and_more.py b/apps/backend/chatballs/products/migrations/0010_alter_marketplacepublication_organization_and_more.py deleted file mode 100644 index 0677c2f..0000000 --- a/apps/backend/chatballs/products/migrations/0010_alter_marketplacepublication_organization_and_more.py +++ /dev/null @@ -1,85 +0,0 @@ -# Generated by Django 5.2.16 on 2026-07-15 00:43 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('identity', '0014_alter_accessprofilecapability_organization_and_more'), - ('products', '0009_marketplacepublication_organization_and_more'), - ] - - operations = [ - migrations.RunSQL( - sql=""" - DO $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM products_productdepartment link - JOIN identity_product product ON product.id = link.product_id - JOIN identity_department department ON department.id = link.department_id - WHERE product.organization_id <> department.organization_id - ) THEN - RAISE EXCEPTION 'C04 preflight: cross-tenant product department exists'; - END IF; - IF EXISTS ( - SELECT 1 - FROM products_offer offer - JOIN products_offer primary_offer ON primary_offer.id = offer.primary_box_offer_id - JOIN identity_product product ON product.id = offer.product_id - JOIN identity_product primary_product ON primary_product.id = primary_offer.product_id - WHERE product.organization_id <> primary_product.organization_id - ) THEN - RAISE EXCEPTION 'C04 preflight: cross-tenant offer relation exists'; - END IF; - END $$; - - UPDATE products_offer offer - SET organization_id = product.organization_id - FROM identity_product product - WHERE offer.product_id = product.id; - - UPDATE products_price price - SET organization_id = product.organization_id - FROM products_offer offer - JOIN identity_product product ON product.id = offer.product_id - WHERE price.offer_id = offer.id; - - UPDATE products_marketplacepublication publication - SET organization_id = product.organization_id - FROM products_price price - JOIN products_offer offer ON offer.id = price.offer_id - JOIN identity_product product ON product.id = offer.product_id - WHERE publication.price_id = price.id; - - UPDATE products_productdepartment link - SET organization_id = product.organization_id - FROM identity_product product - WHERE link.product_id = product.id; - """, - reverse_sql=migrations.RunSQL.noop, - ), - migrations.AlterField( - model_name='marketplacepublication', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.AlterField( - model_name='offer', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.AlterField( - model_name='price', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - migrations.AlterField( - model_name='productdepartment', - name='organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'), - ), - ] diff --git a/apps/backend/chatballs/products/migrations/0011_remove_commerce.py b/apps/backend/chatballs/products/migrations/0011_remove_commerce.py deleted file mode 100644 index 4da3f86..0000000 --- a/apps/backend/chatballs/products/migrations/0011_remove_commerce.py +++ /dev/null @@ -1,20 +0,0 @@ -# ADR-HUB-0041: коммерческий слой каталога (Offer/Price/MarketplacePublication) -# и ingest-токен вебхука заказов удаляются вместе с доменом продаж. -# Зависимость от tenancy.0017: сначала снимаются таблицы orders_*/sales_*, -# ссылавшиеся на products_offer/products_price. -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("products", "0010_alter_marketplacepublication_organization_and_more"), - ("tenancy", "0017_drop_commerce"), - ] - - operations = [ - migrations.RemoveField(model_name="product", name="ingest_token_hash"), - migrations.DeleteModel(name="MarketplacePublication"), - migrations.DeleteModel(name="Price"), - migrations.DeleteModel(name="Offer"), - ] diff --git a/apps/backend/chatballs/products/migrations/0012_delete_productdepartment.py b/apps/backend/chatballs/products/migrations/0012_delete_productdepartment.py deleted file mode 100644 index 808d00b..0000000 --- a/apps/backend/chatballs/products/migrations/0012_delete_productdepartment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.15 on 2026-09-03 22:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - # Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы). - ('tenancy', '0017_drop_commerce'), - ('products', '0011_remove_commerce'), - ] - - operations = [ - migrations.DeleteModel( - name='ProductDepartment', - ), - ] diff --git a/apps/backend/chatballs/products/migrations/__init__.py b/apps/backend/chatballs/products/migrations/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/apps/backend/chatballs/products/migrations/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/backend/chatballs/products/models.py b/apps/backend/chatballs/products/models.py deleted file mode 100644 index c72bd72..0000000 --- a/apps/backend/chatballs/products/models.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from django.core.exceptions import ValidationError -from django.db import models - -from chatballs.identity.crypto import EncryptedCharField - - -class ProductStatus(models.TextChoices): - ACTIVE = "ACTIVE", "Активен" - DISABLED = "DISABLED", "Неактивен" - - -class Product(models.Model): - organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="products") - code = models.SlugField(max_length=64) - name = models.CharField(max_length=255) - status = models.CharField(max_length=32, choices=ProductStatus.choices, default=ProductStatus.ACTIVE) - site_url = models.URLField(blank=True) - # Содержательное описание продукта живёт в Знаниях (ADR-HUB-0023): продукт — - # скрытая техническая запись-якорь для каналов, поддержки и webchat (ADR-HUB-0041). - # Секрет проверки Product Support Token (ADR-HUB-0022, SPEC-HUB-0011 §4.3). - # HS256-секрет для подписи токена поддержки; хранится зашифрованным (Fernet), - # в API не отдаётся. Продукт использует его для подписи in-product support token. - support_token_secret = EncryptedCharField(max_length=512, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = "identity_product" - constraints = [models.UniqueConstraint(fields=["organization", "code"], name="uniq_product_org_code")] - - def __str__(self) -> str: - return f"{self.organization.slug}/{self.code}" diff --git a/apps/backend/chatballs/products/selectors.py b/apps/backend/chatballs/products/selectors.py deleted file mode 100644 index 249dd2a..0000000 --- a/apps/backend/chatballs/products/selectors.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db.models import QuerySet - -from chatballs.products.models import Product -from chatballs.tenancy.context import TenantContext - - -def products_for_context(context: TenantContext) -> QuerySet[Product]: - return ( - Product.objects.filter(organization_id=context.organization_id) - .prefetch_related( - "channels__ai_agent", - "channels__connections", - ) - .order_by("name") - ) - - -def product_for_context(*, context: TenantContext, product_id: int) -> Product: - return products_for_context(context).get(id=product_id) diff --git a/apps/backend/chatballs/products/serializers.py b/apps/backend/chatballs/products/serializers.py deleted file mode 100644 index c7b1d43..0000000 --- a/apps/backend/chatballs/products/serializers.py +++ /dev/null @@ -1,36 +0,0 @@ -from chatballs.products.models import Product - - -def _channel_payload(channel: object) -> dict[str, object]: - # Единый источник каналов продукта: connections + agent, чтобы не - # дублировать выборку отдельным запросом /api/v1/channels/. - agent = getattr(channel, "ai_agent", None) - return { - "id": channel.id, - "code": channel.code, - "name": channel.name, - "isActive": channel.is_active, - "agentId": agent.id if agent else None, - "connections": [ - { - "id": connection.id, - "provider": connection.provider, - "name": connection.name, - "status": connection.status, - } - for connection in sorted(channel.connections.all(), key=lambda item: item.id) - ], - } - - -def product_payload(product: Product) -> dict[str, object]: - return { - "id": product.id, - "code": product.code, - "name": product.name, - "status": product.status, - "siteUrl": product.site_url, - "channels": [_channel_payload(channel) for channel in product.channels.all()], - "createdAt": product.created_at.isoformat(), - "updatedAt": product.updated_at.isoformat(), - } diff --git a/apps/backend/chatballs/products/services.py b/apps/backend/chatballs/products/services.py deleted file mode 100644 index a38d3a0..0000000 --- a/apps/backend/chatballs/products/services.py +++ /dev/null @@ -1,51 +0,0 @@ -from dataclasses import dataclass - -from django.core.exceptions import ValidationError -from django.db import transaction - -from chatballs.products.models import Product, ProductStatus -from chatballs.tenancy.context import TenantContext - - -@dataclass(frozen=True) -class ProductInput: - code: str - name: str - site_url: str = "" - - -@transaction.atomic -def create_product(*, context: TenantContext, data: ProductInput) -> Product: - organization = context.organization - product = Product( - organization=organization, - code=data.code.strip().lower(), - name=data.name.strip(), - status=ProductStatus.ACTIVE, - site_url=data.site_url.strip(), - ) - product.full_clean() - product.save() - return product - - -@transaction.atomic -def update_product(*, context: TenantContext, product: Product, data: ProductInput) -> Product: - if product.organization_id != context.organization_id: - raise ValidationError({"product": "Product belongs to another organization"}) - product.name = data.name.strip() - product.site_url = data.site_url.strip() - product.full_clean(exclude=["code"]) - product.save(update_fields=["name", "site_url", "updated_at"]) - return product - - -def set_product_status( - *, context: TenantContext, product: Product, status: ProductStatus -) -> Product: - if product.organization_id != context.organization_id: - raise ValidationError({"product": "Product belongs to another organization"}) - if product.status != status: - product.status = status - product.save(update_fields=["status", "updated_at"]) - return product diff --git a/apps/backend/chatballs/products/tests.py b/apps/backend/chatballs/products/tests.py deleted file mode 100644 index e1c3a4c..0000000 --- a/apps/backend/chatballs/products/tests.py +++ /dev/null @@ -1,75 +0,0 @@ -import json - -from django.test import TestCase -from chatballs.testing import TenantAPIClient as APIClient - -from chatballs.identity.bootstrap import bootstrap_owner -from chatballs.identity.models import Organization -from chatballs.products.models import Product, ProductStatus - - -class ProductApiTests(TestCase): - def setUp(self) -> None: - bootstrap_owner(email="owner@example.com", password="temporary-password") - self.organization = Organization.objects.get(slug="demo") - self.client = APIClient() - self.client.login(username="owner@example.com", password="temporary-password") - - def test_create_product_is_active(self) -> None: - response = self.client.post( - "/api/v1/company/products/create/", - data=json.dumps( - { - "code": "academy", - "name": "Academy", - "siteUrl": "https://academy.example.com", - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - payload = response.json()["product"] - self.assertEqual(payload["status"], ProductStatus.ACTIVE) - - def test_update_does_not_change_product_code(self) -> None: - product = Product.objects.get(code="site") - response = self.client.patch( - f"/api/v1/company/products/{product.id}/update/", - data=json.dumps( - { - "code": "changed-code", - "name": "Сайт Updated", - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 200) - product.refresh_from_db() - self.assertEqual(product.code, "site") - self.assertEqual(product.name, "Сайт Updated") - - def test_product_detail_is_limited_to_user_organization(self) -> None: - other = Organization.objects.create(name="Other", slug="other") - product = Product.objects.create(organization=other, code="other-product", name="Other Product") - - response = self.client.get(f"/api/v1/company/products/{product.id}/") - - self.assertEqual(response.status_code, 404) - - def test_product_payload_exposes_assigned_channels(self) -> None: - from chatballs.channels.models import Channel - - product = Product.objects.get(code="site") - Channel.objects.create(organization=self.organization, code="site", name="Сайт", product=product) - - response = self.client.get("/api/v1/company/products/") - - payload = next(item for item in response.json()["items"] if item["id"] == product.id) - channel = payload["channels"][0] - self.assertEqual(channel["code"], "site") - self.assertTrue(channel["isActive"]) - # Единый источник: payload содержит связи, нужные табу каналов продукта. - self.assertIsNone(channel["agentId"]) - self.assertEqual(channel["connections"], []) diff --git a/apps/backend/chatballs/products/urls.py b/apps/backend/chatballs/products/urls.py deleted file mode 100644 index a5c88e0..0000000 --- a/apps/backend/chatballs/products/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path - -from chatballs.products import views - -urlpatterns = [ - path("products/", views.ProductListView.as_view(), name="product-list"), - path("products/create/", views.ProductCreateView.as_view(), name="product-create"), - path("products//", views.ProductDetailView.as_view(), name="product-detail"), - path("products//update/", views.ProductUpdateView.as_view(), name="product-update"), - path("products//activate/", views.ProductActivateView.as_view(), name="product-activate"), - path("products//deactivate/", views.ProductDeactivateView.as_view(), name="product-deactivate"), -] diff --git a/apps/backend/chatballs/products/views.py b/apps/backend/chatballs/products/views.py deleted file mode 100644 index 9e21a74..0000000 --- a/apps/backend/chatballs/products/views.py +++ /dev/null @@ -1,135 +0,0 @@ -from django.core.exceptions import ValidationError -from django.db import IntegrityError -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from chatballs.api.permissions import HasCapability -from chatballs.identity.audit import record_audit_event -from chatballs.products.models import Product, ProductStatus -from chatballs.products.selectors import product_for_context, products_for_context -from chatballs.products.serializers import product_payload -from chatballs.products.services import ProductInput, create_product, set_product_status, update_product -def _input(body: dict[str, object], *, current: Product | None = None) -> ProductInput: - return ProductInput( - code=str(body.get("code", current.code if current else "")), - name=str(body.get("name", current.name if current else "")), - site_url=str(body.get("siteUrl", current.site_url if current else "")), - ) - - -def _validation_error(error: Exception) -> Response: - if isinstance(error, ValidationError): - if hasattr(error, "message_dict"): - detail = "; ".join(message for messages in error.message_dict.values() for message in messages) - else: - detail = "; ".join(error.messages) - else: - detail = "Product code already exists" - return Response({"detail": detail}, status=400) - - -class ProductListView(APIView): - permission_classes = [HasCapability] - required_capability = "products.view" - - def get(self, request: Request) -> Response: - products = products_for_context(request.tenant_context) - return Response({"items": [product_payload(product) for product in products]}) - - -class ProductCreateView(APIView): - permission_classes = [HasCapability] - required_capability = "products.manage" - - def post(self, request: Request) -> Response: - profile = request.tenant_context.membership - data = _input(request.data) - try: - product = create_product(context=request.tenant_context, data=data) - except (ValidationError, IntegrityError) as error: - return _validation_error(error) - record_audit_event( - action="products.product_created", - actor=request.user, - organization=profile.organization, - object_type="Product", - object_id=str(product.id), - request=request, - ) - product = product_for_context(context=request.tenant_context, product_id=product.id) - return Response({"product": product_payload(product)}, status=201) - - -class ProductDetailView(APIView): - permission_classes = [HasCapability] - required_capability = "products.view" - - def get(self, request: Request, product_id: int) -> Response: - try: - product = product_for_context(context=request.tenant_context, product_id=product_id) - except Product.DoesNotExist: - return Response({"detail": "Product not found"}, status=404) - return Response({"product": product_payload(product)}) - - -class ProductUpdateView(APIView): - permission_classes = [HasCapability] - required_capability = "products.manage" - - def patch(self, request: Request, product_id: int) -> Response: - profile = request.tenant_context.membership - try: - product = product_for_context(context=request.tenant_context, product_id=product_id) - data = _input(request.data, current=product) - product = update_product( - context=request.tenant_context, product=product, data=data - ) - except Product.DoesNotExist: - return Response({"detail": "Product not found"}, status=404) - except (ValidationError, IntegrityError) as error: - return _validation_error(error) - record_audit_event( - action="products.product_updated", - actor=request.user, - organization=profile.organization, - object_type="Product", - object_id=str(product.id), - request=request, - ) - product = product_for_context(context=request.tenant_context, product_id=product.id) - return Response({"product": product_payload(product)}) - - -class ProductStatusView(APIView): - permission_classes = [HasCapability] - required_capability = "products.manage" - status_value: ProductStatus - - def post(self, request: Request, product_id: int) -> Response: - profile = request.tenant_context.membership - try: - product = Product.objects.get(id=product_id, organization=profile.organization) - except Product.DoesNotExist: - return Response({"detail": "Product not found"}, status=404) - set_product_status( - context=request.tenant_context, product=product, status=self.status_value - ) - record_audit_event( - action=f"products.product_{self.status_value.lower()}", - actor=request.user, - organization=profile.organization, - object_type="Product", - object_id=str(product.id), - request=request, - ) - product = product_for_context(context=request.tenant_context, product_id=product.id) - return Response({"product": product_payload(product)}) - - -class ProductActivateView(ProductStatusView): - status_value = ProductStatus.ACTIVE - - -class ProductDeactivateView(ProductStatusView): - status_value = ProductStatus.DISABLED diff --git a/apps/backend/chatballs/support/__init__.py b/apps/backend/chatballs/support/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/backend/chatballs/support/admin.py b/apps/backend/chatballs/support/admin.py deleted file mode 100644 index 6b6c1c5..0000000 --- a/apps/backend/chatballs/support/admin.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib import admin - -from chatballs.support.models import ProductSupportContract, SupportIdentitySnapshot - - -@admin.register(ProductSupportContract) -class ProductSupportContractAdmin(admin.ModelAdmin): - list_display = ("code", "version", "product", "organization", "status", "updated_at") - list_filter = ("status", "organization") - search_fields = ("code", "product__code", "product__name") - filter_horizontal = ("allowed_channels",) - - -@admin.register(SupportIdentitySnapshot) -class SupportIdentitySnapshotAdmin(admin.ModelAdmin): - list_display = ("subject_key", "product", "contract_code", "display_name", "verified_at") - list_filter = ("product",) - search_fields = ("subject_key", "display_name", "display_email", "search_text") - readonly_fields = ("verified_at", "created_at", "payload_json", "token_jti_hash") diff --git a/apps/backend/chatballs/support/apps.py b/apps/backend/chatballs/support/apps.py deleted file mode 100644 index 7d5498e..0000000 --- a/apps/backend/chatballs/support/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.apps import AppConfig - - -class SupportConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - label = "support" - name = "chatballs.support" - verbose_name = "Support department and product identity contracts" diff --git a/apps/backend/chatballs/support/errors.py b/apps/backend/chatballs/support/errors.py deleted file mode 100644 index acc79aa..0000000 --- a/apps/backend/chatballs/support/errors.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Машинные коды ошибок support-сессии (SPEC-HUB-0011 §14). - -Публичный ответ widget всегда безопасный: {"error":"support_unavailable","message":...}. -Внутренний machine code пишется в audit payload и не раскрывается пользователю. -""" - -from __future__ import annotations - -# Публичное сообщение для всех категорий отказа — без технических деталей. -PUBLIC_SUPPORT_UNAVAILABLE = ( - "Поддержка временно недоступна. Обновите страницу или обратитесь позже." -) - - -# Внутренние machine codes для audit/debug. -TOKEN_MISSING = "TOKEN_MISSING" -TOKEN_SIGNATURE_INVALID = "TOKEN_SIGNATURE_INVALID" -TOKEN_EXPIRED = "TOKEN_EXPIRED" -TOKEN_AUDIENCE_INVALID = "TOKEN_AUDIENCE_INVALID" -TOKEN_ISSUER_INVALID = "TOKEN_ISSUER_INVALID" -CONTRACT_NOT_FOUND = "CONTRACT_NOT_FOUND" -CONTRACT_DISABLED = "CONTRACT_DISABLED" -CONTRACT_CHANNEL_NOT_ALLOWED = "CONTRACT_CHANNEL_NOT_ALLOWED" -PAYLOAD_SCHEMA_INVALID = "PAYLOAD_SCHEMA_INVALID" -SUBJECT_MAPPING_EMPTY = "SUBJECT_MAPPING_EMPTY" -CHANNEL_NOT_SUPPORT = "CHANNEL_NOT_SUPPORT" -CHANNEL_PRODUCT_MISMATCH = "CHANNEL_PRODUCT_MISMATCH" -ANONYMOUS_NOT_ALLOWED = "ANONYMOUS_NOT_ALLOWED" - - -class SupportSessionError(Exception): - """Отказ старта support-сессии с машинным кодом причины. - - code — внутренний machine code (для audit); пользователю отдаётся только - безопасное публичное сообщение без раскрытия деталей проверки. - """ - - def __init__(self, code: str, message: str = PUBLIC_SUPPORT_UNAVAILABLE) -> None: - super().__init__(message) - self.code = code diff --git a/apps/backend/chatballs/support/extract.py b/apps/backend/chatballs/support/extract.py deleted file mode 100644 index 7913b36..0000000 --- a/apps/backend/chatballs/support/extract.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Извлечение identity/operator/ai/search контекста по контракту (SPEC-HUB-0011 §6-11). - -Валидация payload по schema_json — ручная (нет jsonschema-зависимости): -проверка required-полей object и вложенных object-properties, pattern из orders/views.py. -Глубокая type-проверка — TODO. -""" - -from __future__ import annotations - -from typing import Any - -from chatballs.support import errors, jsonpath -from chatballs.support.models import ProductSupportContract - - -def validate_schema(data: dict[str, Any], schema: dict[str, Any]) -> None: - """Ручная проверка required-полей по schema. Поднимает PAYLOAD_SCHEMA_INVALID.""" - if not schema: - return - required = schema.get("required") or [] - if isinstance(required, list): - for field in required: - if field not in data: - raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID) - # Рекурсивная проверка required во вложенных object-properties. - properties = schema.get("properties") or {} - if isinstance(properties, dict): - for field, subschema in properties.items(): - if not isinstance(subschema, dict): - continue - sub_required = subschema.get("required") - value = data.get(field) - if isinstance(sub_required, list) and isinstance(value, dict): - for sub_field in sub_required: - if sub_field not in value: - raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID) - - -def extract_context(data: dict[str, Any], contract: ProductSupportContract) -> dict[str, Any]: - """Извлекает identity/operator_cards/ai_context/search по mapping'ам контракта.""" - identity = contract.identity_mapping_json or {} - subject = jsonpath.resolve_path(data, identity.get("subject", "")) - if not subject: - raise errors.SupportSessionError(errors.SUBJECT_MAPPING_EMPTY) - - account = jsonpath.resolve_path(data, identity.get("account", "")) - display_name = jsonpath.resolve_path(data, identity.get("display_name", "")) or "" - display_email = jsonpath.resolve_path(data, identity.get("display_email", "")) or "" - - operator_cards = build_operator_cards(data, contract.operator_ui_json or {}) - ai_context = build_ai_context(data, contract.ai_context_json or {}) - search_text = build_search_text(data, contract.search_mapping_json or {}) - - return { - "subject_key": str(subject), - "account_key": str(account) if account else "", - "display_name": str(display_name), - "display_email": str(display_email), - "operator_context": {"operator_cards": operator_cards}, - "ai_context": ai_context, - "search_text": search_text, - } - - -def build_operator_cards(data: dict[str, Any], ui: dict[str, Any]) -> list[dict[str, Any]]: - cards: list[dict[str, Any]] = [] - for card in ui.get("operator_cards", []) or []: - fields = [] - for field in card.get("fields", []) or []: - value = jsonpath.resolve_path(data, field.get("path", "")) - fields.append( - { - "label": field.get("label", ""), - "value": value, - "type": field.get("type", "text"), - "visibility": field.get("visibility", "operator"), - } - ) - cards.append({"title": card.get("title", ""), "fields": fields}) - return cards - - -def build_ai_context(data: dict[str, Any], ai: dict[str, Any]) -> dict[str, Any]: - allowed = ai.get("allowed_paths", []) or [] - values: dict[str, Any] = {} - for path in allowed: - values[path] = jsonpath.resolve_path(data, path) - return {"allowed_paths": values} - - -def build_search_text(data: dict[str, Any], search: dict[str, Any]) -> str: - paths = search.get("paths", []) or [] - parts = [str(jsonpath.resolve_path(data, p)) for p in paths] - return " ".join(p for p in parts if p and p != "None") diff --git a/apps/backend/chatballs/support/jsonpath.py b/apps/backend/chatballs/support/jsonpath.py deleted file mode 100644 index af58f31..0000000 --- a/apps/backend/chatballs/support/jsonpath.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Ограниченный JSONPath-subset для mapping'ов контракта (SPEC-HUB-0011 §6). - -Допускаются только: - $.field - $.field.nested - $.array[0].field — только целочисленный индекс, без filters/scripts - -Произвольные expressions, filters или script-like expressions не допускаются. -Отсутствующий обязательный path обрабатывается вызывающей стороной (subject). -""" - -from __future__ import annotations - -from typing import Any - -_MISSING = object() - -# Унификация: принимаем как "$.a.b", так и "a.b" (без префикса). -_PREFIX = "$." - - -def _normalize(path: str) -> str: - if path.startswith(_PREFIX): - return path[len(_PREFIX):] - return path - - -def resolve_path(data: Any, path: str) -> Any: - """Возвращает значение по path или None, если path отсутствует. - - None для optional paths; вызывающая сторона решает, обязателен ли path. - Некорректный path (не массив по индексу и т.п.) тоже даёт None — контракт - не должен ронять renderer на отсутствующих optional полях (SPEC §6). - """ - if not path or not isinstance(data, dict | list): - return None - current: Any = data - for segment in _normalize(path).split("."): - if segment == "": - continue - # Поддержка array[0] внутри сегмента: field[2] - idx = _array_index(segment) - if idx is not None: - name = segment.split("[", 1)[0] - current = _step(current, name) - if current is _MISSING: - return None - current = _index(current, idx) - if current is _MISSING: - return None - else: - current = _step(current, segment) - if current is _MISSING: - return None - return None if current is _MISSING else current - - -def _step(current: Any, name: str) -> Any: - if isinstance(current, dict): - return current.get(name, _MISSING) - return _MISSING - - -def _index(current: Any, idx: int) -> Any: - if isinstance(current, list) and -len(current) <= idx < len(current): - return current[idx] - return _MISSING - - -def _array_index(segment: str) -> int | None: - if "[" not in segment or not segment.endswith("]"): - return None - inner = segment[segment.index("[") + 1 : -1] - if not inner.lstrip("-").isdigit(): - return None - return int(inner) diff --git a/apps/backend/chatballs/support/messages.py b/apps/backend/chatballs/support/messages.py deleted file mode 100644 index 53fb2d4..0000000 --- a/apps/backend/chatballs/support/messages.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Poll/send сообщений support-диалога для виджета (SPEC-HUB-0010 §7). - -В отличие от sales webchat (ingest_inbound), support-путь НЕ создаёт Contact и НЕ -ходит в мессенджер (transports.send_reply): ответ оператора/AI виджет забирает -polling'ом. AI-путь (run_channel_turn + handoff + system) переиспользуется. -""" - -from __future__ import annotations - -import logging - -from django.db import transaction -from django.utils import timezone - -from chatballs.ai.limits import LimitExceeded -from chatballs.ai.provider.base import ProviderError -from chatballs.ai.runtime import HANDOFF_TOKEN -from chatballs.channels.runtime import run_channel_turn -from chatballs.conversations.models import ( - ControlMode, - Conversation, - ExpectedResponder, - Message, - MessageAuthor, - MessageKind, -) -from chatballs.notifications.models import NotificationAudience, NotificationType -from chatballs.notifications.services import notify, notify_management -from chatballs.tenancy.context import TenantContext - -logger = logging.getLogger(__name__) - -_HISTORY_LIMIT = 20 -_ROLE = { - MessageAuthor.CONTACT: "user", - MessageAuthor.AI: "assistant", - MessageAuthor.OPERATOR: "assistant", - MessageAuthor.SYSTEM: "system", -} -_STATE = { - ControlMode.AI: "ai", - ControlMode.HUMAN: "operator", - ControlMode.PAUSED: "waiting", -} -_AUTHOR = { - MessageAuthor.CONTACT: "client", - MessageAuthor.AI: "ai", - MessageAuthor.OPERATOR: "operator", - MessageAuthor.SYSTEM: "system", -} - - -def _history(conversation: Conversation) -> list[dict]: - messages = list(conversation.messages.order_by("created_at")) - prior = messages[:-1][-_HISTORY_LIMIT:] - # Голосовые попадают в контекст стенограммой. - return [{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript} for m in prior if m.text or m.transcript] - - -def support_messages_since(conversation: Conversation, since: int) -> dict: - items = conversation.messages.filter(id__gt=since).order_by("created_at") - return { - "state": _STATE.get(conversation.control_mode, "ai"), - "lifecycle": conversation.lifecycle, - "messages": [ - { - "id": m.id, - "author": _AUTHOR.get(m.author_type, "ai"), - "kind": m.kind, - "text": m.text, - "createdAt": m.created_at.isoformat(), - "durationSeconds": m.duration_seconds, - "hasAudio": bool(m.audio), - **( - { - "attachment": { - "name": m.attachment_name, - "contentType": m.attachment_content_type, - "size": m.attachment_size, - "available": bool(m.attachment), - } - } - if m.kind == MessageKind.FILE - else {} - ), - } - for m in items - ], - } - - -@transaction.atomic -def post_support_message( - *, context: TenantContext, conversation: Conversation, text: str -) -> None: - """Сохраняет сообщение клиента и запускает AI-ответ (если диалог ведёт AI). - - ADR-HUB-0003: AI-first; handoff AI→operator. Без Contact/ConnectionIdentity и - без transports.send_reply (ответ идёт через polling, не через messenger API). - """ - if conversation.organization_id != context.organization_id: - raise ValueError("Support conversation is outside tenant context") - Message.objects.create(conversation=conversation, author_type=MessageAuthor.CONTACT, text=text) - conversation.last_activity_at = timezone.now() - conversation.save(update_fields=["last_activity_at"]) - _run_support_ai(context=context, conversation=conversation, text=text) - - -def _client_label(conversation: Conversation) -> str: - snapshot = conversation.support_identity_snapshot - return (snapshot.display_name if snapshot else "") or "Клиент" - - -def _hand_to_operator(*, context: TenantContext, conversation: Conversation, reason: str) -> None: - """Диалог уходит оператору без имитации сбоя AI (голосовое без стенограммы, файл).""" - if conversation.control_mode != ControlMode.AI: - return - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "expected_responder"]) - notify( - context=context, - type=NotificationType.DIALOG_WAITING, - audience=NotificationAudience.OPERATORS, - title=f"Нужен оператор · {_client_label(conversation)}", - body=reason, - target_id=conversation.id, - source_type="Conversation", - source_id=conversation.id, - dedup_key=f"media:{conversation.id}", - ) - - -@transaction.atomic -def post_support_voice(*, context: TenantContext, conversation: Conversation, content: bytes, content_type: str, duration: int) -> Message: - """Голосовое из портала поддержки: сохраняем, AI отвечает текстом по - стенограмме; без расшифровки — диалог оператору.""" - from django.core.files.base import ContentFile - - from chatballs.conversations.ingest import transcribe_voice_message - - if conversation.organization_id != context.organization_id: - raise ValueError("Support conversation is outside tenant context") - message = Message.objects.create( - conversation=conversation, - author_type=MessageAuthor.CONTACT, - kind=MessageKind.VOICE, - audio_content_type=content_type, - duration_seconds=duration, - ) - suffix = "ogg" if "ogg" in content_type else content_type.rsplit("/", 1)[-1] - message.audio.save(f"voice.{suffix}", ContentFile(content), save=False) - message.save(update_fields=["audio"]) - conversation.last_activity_at = timezone.now() - conversation.save(update_fields=["last_activity_at"]) - if conversation.control_mode != ControlMode.AI: - return message - transcript = transcribe_voice_message(conversation.channel, message) - if transcript: - _run_support_ai(context=context, conversation=conversation, text=transcript) - else: - _hand_to_operator(context=context, conversation=conversation, reason="Голосовое без расшифровки") - return message - - -@transaction.atomic -def post_support_file(*, context: TenantContext, conversation: Conversation, content: bytes, filename: str, content_type: str, caption: str = "") -> Message: - """Файл из портала поддержки: подпись — обычное сообщение (с ответом AI), - сам файл — отдельная реплика; файл без подписи уводит диалог оператору.""" - from django.core.files.base import ContentFile - - if conversation.organization_id != context.organization_id: - raise ValueError("Support conversation is outside tenant context") - if caption: - Message.objects.create(conversation=conversation, author_type=MessageAuthor.CONTACT, text=caption) - message = Message.objects.create( - conversation=conversation, - author_type=MessageAuthor.CONTACT, - kind=MessageKind.FILE, - attachment_name=filename, - attachment_content_type=content_type, - attachment_size=len(content), - ) - message.attachment.save(filename, ContentFile(content), save=False) - message.save(update_fields=["attachment"]) - conversation.last_activity_at = timezone.now() - conversation.save(update_fields=["last_activity_at"]) - if caption: - _run_support_ai(context=context, conversation=conversation, text=caption) - else: - _hand_to_operator(context=context, conversation=conversation, reason=f"Файл: {filename}") - return message - - -def _run_support_ai(*, context: TenantContext, conversation: Conversation, text: str) -> None: - client_label = _client_label(conversation) - # AI отвечает только когда диалог ведёт AI (ADR-HUB-0003). - if conversation.control_mode != ControlMode.AI: - return - - try: - result = run_channel_turn( - channel=conversation.channel, message=text, history=_history(conversation) - ) - except (ProviderError, LimitExceeded) as error: - logger.warning("AI turn failed for support conversation %s: %s", conversation.id, error) - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.last_activity_at = timezone.now() - conversation.save( - update_fields=["control_mode", "expected_responder", "last_activity_at"] - ) - Message.objects.create( - conversation=conversation, - author_type=MessageAuthor.SYSTEM, - text="AI недоступен — диалог передан оператору", - ) - fallback = ( - "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос" - " специалисту — он скоро подключится." - ) - Message.objects.create( - conversation=conversation, author_type=MessageAuthor.AI, text=fallback - ) - notify( - context=context, - type=NotificationType.DIALOG_WAITING, - audience=NotificationAudience.OPERATORS, - title=f"Нужен оператор · {client_label}", - body="AI временно недоступен, диалог ждёт ответа", - target_id=conversation.id, - source_type="Conversation", - source_id=conversation.id, - dedup_key=f"aifail:{conversation.id}", - ) - notify_management( - context=context, - type=NotificationType.INTEGRATION_ERROR, - title=f"Ошибка AI · {conversation.channel.name}", - body="AI временно недоступен, диалог передан оператору", - target_id=conversation.id, - source_type="Conversation", - source_id=conversation.id, - dedup_key=f"aierror:{conversation.id}", - ) - return - - reply = result.text - handoff = HANDOFF_TOKEN in reply - if handoff: - reply = reply.replace(HANDOFF_TOKEN, "").strip() - - Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply) - conversation.last_activity_at = timezone.now() - if handoff: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - else: - conversation.expected_responder = ExpectedResponder.CUSTOMER - conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"]) - - if handoff: - Message.objects.create( - conversation=conversation, - author_type=MessageAuthor.SYSTEM, - text="AI передал диалог оператору", - ) - notify( - context=context, - type=NotificationType.DIALOG_WAITING, - audience=NotificationAudience.OPERATORS, - title=f"AI передал диалог · {client_label}", - body=text[:120], - target_id=conversation.id, - source_type="Conversation", - source_id=conversation.id, - dedup_key=f"handoff:{conversation.id}", - ) diff --git a/apps/backend/chatballs/support/migrations/0001_initial.py b/apps/backend/chatballs/support/migrations/0001_initial.py deleted file mode 100644 index 118a2c6..0000000 --- a/apps/backend/chatballs/support/migrations/0001_initial.py +++ /dev/null @@ -1,77 +0,0 @@ -# Generated by Django 5.2.15 on 2026-07-08 21:26 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('channels', '0002_channel_system_prompt'), - ('identity', '0006_alter_employeeprofile_totp_secret'), - ('products', '0006_product_ingest_token_hash'), - ] - - operations = [ - migrations.CreateModel( - name='ProductSupportContract', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.SlugField(max_length=64)), - ('version', models.PositiveSmallIntegerField()), - ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('ACTIVE', 'Активен'), ('DEPRECATED', 'Устаревший'), ('DISABLED', 'Отключён')], default='DRAFT', max_length=16)), - ('schema_json', models.JSONField(blank=True, default=dict)), - ('identity_mapping_json', models.JSONField(blank=True, default=dict)), - ('operator_ui_json', models.JSONField(blank=True, default=dict)), - ('ai_context_json', models.JSONField(blank=True, default=dict)), - ('search_mapping_json', models.JSONField(blank=True, default=dict)), - ('sensitive_fields_json', models.JSONField(blank=True, default=dict)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('allowed_channels', models.ManyToManyField(blank=True, related_name='allowed_support_contracts', to='channels.channel')), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_contracts', to='identity.organization')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_contracts', to='products.product')), - ], - ), - migrations.CreateModel( - name='SupportIdentitySnapshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('contract_code', models.CharField(max_length=64)), - ('subject_key', models.CharField(db_index=True, max_length=128)), - ('account_key', models.CharField(blank=True, db_index=True, max_length=128, null=True)), - ('display_name', models.CharField(blank=True, max_length=255)), - ('display_email', models.CharField(blank=True, max_length=255)), - ('payload_json', models.JSONField(blank=True, default=dict)), - ('operator_context_json', models.JSONField(blank=True, default=dict)), - ('ai_context_json', models.JSONField(blank=True, default=dict)), - ('search_text', models.TextField(blank=True)), - ('verified_at', models.DateTimeField(auto_now_add=True, db_index=True)), - ('token_issued_at', models.DateTimeField()), - ('token_expires_at', models.DateTimeField()), - ('token_jti_hash', models.CharField(blank=True, db_index=True, max_length=64)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('contract', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='snapshots', to='support.productsupportcontract')), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_identity_snapshots', to='identity.organization')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_identity_snapshots', to='products.product')), - ], - ), - migrations.AddIndex( - model_name='productsupportcontract', - index=models.Index(fields=['product', 'status'], name='support_pro_product_962332_idx'), - ), - migrations.AddConstraint( - model_name='productsupportcontract', - constraint=models.UniqueConstraint(fields=('organization', 'code'), name='uniq_support_contract_org_code'), - ), - migrations.AddIndex( - model_name='supportidentitysnapshot', - index=models.Index(fields=['product', 'subject_key'], name='support_sup_product_155363_idx'), - ), - migrations.AddIndex( - model_name='supportidentitysnapshot', - index=models.Index(fields=['product', 'account_key'], name='support_sup_product_ae9686_idx'), - ), - ] diff --git a/apps/backend/chatballs/support/migrations/0002_alter_productsupportcontract_code.py b/apps/backend/chatballs/support/migrations/0002_alter_productsupportcontract_code.py deleted file mode 100644 index 9c4ba20..0000000 --- a/apps/backend/chatballs/support/migrations/0002_alter_productsupportcontract_code.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.15 on 2026-07-08 22:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('support', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='productsupportcontract', - name='code', - field=models.CharField(max_length=64), - ), - ] diff --git a/apps/backend/chatballs/support/migrations/__init__.py b/apps/backend/chatballs/support/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/backend/chatballs/support/models.py b/apps/backend/chatballs/support/models.py deleted file mode 100644 index 30b0904..0000000 --- a/apps/backend/chatballs/support/models.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -import re - -from django.core.exceptions import ValidationError -from django.db import models - -# Отдел поддержки — authenticated in-product чат (ADR-HUB-0022, SPEC-HUB-0010/0011). -# Hub не владеет бизнес-моделью клиента продукта: каждый продукт публикует -# версионированный ProductSupportIdentityContract, а Hub хранит проверенный -# SupportIdentitySnapshot после проверки Product Support Token. Raw token нигде -# не сохраняется — только короткий хэш jti для audit/replay. - -# Формат code контракта: .support.v (SPEC-HUB-0011 §3). -_CONTRACT_CODE_RE = re.compile( - r"^(?P[a-z0-9-]+)\.support\.v(?P[1-9][0-9]*)$" -) - - -class ContractStatus(models.TextChoices): - DRAFT = "DRAFT", "Черновик" - ACTIVE = "ACTIVE", "Активен" - DEPRECATED = "DEPRECATED", "Устаревший" - DISABLED = "DISABLED", "Отключён" - - -class ProductSupportContract(models.Model): - """Версионированный контракт идентификации клиента поддержки продукта. - - Описывает JSON Schema payload токена, mapping identity, карточки оператора, - AI-visible context, search и sensitive fields (SPEC-HUB-0011 §5). - """ - - organization = models.ForeignKey( - "identity.Organization", - on_delete=models.PROTECT, - related_name="support_contracts", - ) - product = models.ForeignKey( - "products.Product", - on_delete=models.PROTECT, - related_name="support_contracts", - ) - # Формат .support.v содержит точки, поэтому CharField, - # а не SlugField (SlugField не допускает '.'). Формат проверяется в clean(). - code = models.CharField(max_length=64) - version = models.PositiveSmallIntegerField() - status = models.CharField( - max_length=16, - choices=ContractStatus.choices, - default=ContractStatus.DRAFT, - ) - allowed_channels = models.ManyToManyField( - "channels.Channel", - related_name="allowed_support_contracts", - blank=True, - ) - schema_json = models.JSONField(default=dict, blank=True) - identity_mapping_json = models.JSONField(default=dict, blank=True) - operator_ui_json = models.JSONField(default=dict, blank=True) - ai_context_json = models.JSONField(default=dict, blank=True) - search_mapping_json = models.JSONField(default=dict, blank=True) - sensitive_fields_json = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["organization", "code"], - name="uniq_support_contract_org_code", - ), - ] - indexes = [models.Index(fields=["product", "status"])] - - def __str__(self) -> str: - return f"{self.organization.slug}/{self.code}" - - def clean(self) -> None: - super().clean() - if self.product.organization_id != self.organization_id: - raise ValidationError({"product": "Product must belong to the same organization"}) - match = _CONTRACT_CODE_RE.match(self.code) - if match is None: - raise ValidationError( - {"code": "Contract code must be '.support.v'"} - ) - if match.group("product_code") != self.product.code: - raise ValidationError({"code": "Contract code product part must match product code"}) - if self.version != int(match.group("version")): - raise ValidationError({"version": "Version must match the major in contract code"}) - - -class SupportIdentitySnapshot(models.Model): - """Проверенный снимок identity/context клиента поддержки (SPEC-HUB-0011 §13). - - Исторический снимок: если продукт позже изменил данные или структуру payload, - старый диалог остаётся читаемым в контексте того обращения. Raw token не - сохраняется — только хэш jti для audit/replay-обнаружения. - """ - - organization = models.ForeignKey( - "identity.Organization", - on_delete=models.PROTECT, - related_name="support_identity_snapshots", - ) - product = models.ForeignKey( - "products.Product", - on_delete=models.PROTECT, - related_name="support_identity_snapshots", - ) - contract = models.ForeignKey( - ProductSupportContract, - on_delete=models.PROTECT, - related_name="snapshots", - ) - contract_code = models.CharField(max_length=64) - subject_key = models.CharField(max_length=128, db_index=True) - account_key = models.CharField(max_length=128, blank=True, null=True, db_index=True) - display_name = models.CharField(max_length=255, blank=True) - display_email = models.CharField(max_length=255, blank=True) - payload_json = models.JSONField(default=dict, blank=True) - operator_context_json = models.JSONField(default=dict, blank=True) - ai_context_json = models.JSONField(default=dict, blank=True) - search_text = models.TextField(blank=True) - verified_at = models.DateTimeField(auto_now_add=True, db_index=True) - token_issued_at = models.DateTimeField() - token_expires_at = models.DateTimeField() - token_jti_hash = models.CharField(max_length=64, blank=True, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - indexes = [ - models.Index(fields=["product", "subject_key"]), - models.Index(fields=["product", "account_key"]), - ] - - def __str__(self) -> str: - return f"snapshot:{self.id}/{self.subject_key}" diff --git a/apps/backend/chatballs/support/public_urls.py b/apps/backend/chatballs/support/public_urls.py deleted file mode 100644 index be0d490..0000000 --- a/apps/backend/chatballs/support/public_urls.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.urls import path - -from chatballs.support import public_views - -urlpatterns = [ - path( - "sessions/", - public_views.SupportSessionStartView.as_view(), - name="support-session-start", - ), - path( - "sessions/messages/", - public_views.SupportSessionMessagesView.as_view(), - name="support-session-messages", - ), - path( - "sessions/messages//audio/", - public_views.SupportSessionAudioView.as_view(), - name="support-session-audio", - ), - path( - "sessions/messages//attachment/", - public_views.SupportSessionAttachmentView.as_view(), - name="support-session-attachment", - ), -] diff --git a/apps/backend/chatballs/support/public_views.py b/apps/backend/chatballs/support/public_views.py deleted file mode 100644 index bb459b1..0000000 --- a/apps/backend/chatballs/support/public_views.py +++ /dev/null @@ -1,294 +0,0 @@ -from django.db import models -from django.http import FileResponse -from rest_framework.parsers import FormParser, JSONParser, MultiPartParser -from rest_framework.permissions import AllowAny -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from chatballs.conversations.attachment_views import attachment_response, validate_upload -from chatballs.conversations.models import Conversation, Message, MessageKind -from chatballs.conversations.serializers import conversation_payload -from chatballs.conversations.transports.base import guess_content_type, safe_filename -from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES -from chatballs.integrations.features import features_payload, voice_messages_allowed -from chatballs.identity.models import Organization -from chatballs.support import errors -from chatballs.support.messages import post_support_file, post_support_message, post_support_voice, support_messages_since -from chatballs.support.serializers import support_identity_snapshot_payload -from chatballs.support.session import start_support_session -from chatballs.support.token import verify_support_token -from chatballs.support.widget_credential import verify_widget_credential -from chatballs.tenancy.context import TenantContext -from chatballs.tenancy.database import tenant_atomic -from chatballs.tenancy.ingress import ( - support_channel_routes, - support_conversation_route, - web_widget_route, -) -from chatballs.webchat.models import ( - WebChatWidget, - WebChatWidgetMode, - WebChatWidgetStatus, -) -from chatballs.webchat.services import origin_allowed - - -class _Public(APIView): - authentication_classes: list = [] - permission_classes = [AllowAny] - - -class SupportSessionStartView(_Public): - def post(self, request: Request) -> Response: - widget_key = str(request.data.get("widgetKey", "")).strip() - channel_code = str(request.data.get("channel", "")).strip() - token = str(request.data.get("token", "")) - if not token or (not widget_key and not channel_code): - return _denied() - if widget_key: - route = web_widget_route(widget_key) - if route is None: - return _denied() - channel_id = None - else: - routes = support_channel_routes(channel_code) - if len(routes) == 1: - route = routes[0] - else: - verified = [] - for candidate in routes: - try: - verify_support_token(token=token, secret=candidate.support_secret) - except errors.SupportSessionError: - continue - verified.append(candidate) - if len(verified) != 1: - return _denied() - route = verified[0] - channel_id = int(route.resource_id) - try: - organization = Organization.objects.get(pk=route.organization_id) - except Organization.DoesNotExist: - return _denied() - context = TenantContext.for_resource(organization) - with tenant_atomic(context): - widgets = WebChatWidget.objects.select_related( - "organization", - "integration", - "integration__channel", - "integration__channel__product", - ).filter( - organization=organization, - mode=WebChatWidgetMode.AUTHENTICATED_PRODUCT, - status=WebChatWidgetStatus.PUBLISHED, - integration__provider="WEB", - integration__status="OK", - integration__is_active=True, - integration__channel__is_active=True, - ) - if widget_key: - widget = widgets.filter( - id=route.resource_id, - public_key=widget_key, - ).first() - else: - candidates = list( - widgets.filter( - integration__channel_id=channel_id, - integration__channel__code=channel_code, - ).order_by("id")[:2] - ) - widget = candidates[0] if len(candidates) == 1 else None - origin = str( - request.data.get("hostOrigin", "") - or request.headers.get("Origin") - or request.headers.get("Referer") - or "" - ) - if widget is None or not origin_allowed(widget, origin): - return _denied() - try: - result = start_support_session( - widget=widget, - token=token, - request=request, - ) - except errors.SupportSessionError: - return _denied() - return Response( - { - "conversation": conversation_payload( - result["conversation"], - with_messages=True, - ), - "snapshot": support_identity_snapshot_payload(result["snapshot"]), - "widgetCredential": result["widget_credential"], - # Что разрешено в этой точке входа: виджет прячет микрофон при запрете. - "features": features_payload(widget.integration), - }, - status=201, - ) - - -def _widget_context(request: Request): - auth = request.headers.get("Authorization", "") - #