✨ feat(seed): replace standalone importer with Django seed_demo command and Russian IIoT dataset

Старый сид dev/local-seed/ (standalone-скрипт под CUS_ENV) заменён полноценной
Django-командой seed_demo с редактируемыми JSON-манифестами в demo_seed/data/.
Полный выдуманный датасет организации «Северная Верфь» (IIoT, РФ-наполнение):
продукты Вектор/Репер, каналы, AI-агенты, база знаний со вложениями, диалоги,
заказы, продажи, поддержка, портал Help-центра, звонки, уведомления.
Идемпотентен (get_or_create/update_or_create), dry-run по умолчанию (--apply),
--force для облака. Удалён dev/local-seed/, скрипты seed-local/seed-support-portal,
compose-профиль переключен на demo-seed. Тесты: создание, идемпотентность, dry-run.
This commit is contained in:
Andrey committed 2026-07-30 19:59:46 +03:00
1 parent 12f189cc60
commit 4c622c7542
33 files changed
+2334 -909

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ instance/
__pycache__/
*.py[cod]
apps/backend/staticfiles/
apps/backend/media/
.pytest_cache/
.ruff_cache/
.mypy_cache/
+20 -20
View File
@@ -54,36 +54,36 @@ Bootstrap the organization and both accounts:
docker compose run --rm backend-app python manage.py bootstrap_owner --email owner@edevs.tech --password Owner-Local-2026 --name "Иван Петров"
```
## Optional local demo seed
## Optional demo seed
The local demo seed is explicit and idempotent: it creates the documented local
accounts and a connected set of departments, products, channels, integrations,
AI agents, conversations, orders, and command-center data. It never runs during
`start.ps1`, `docker compose up`, or migrations.
The demo seed is a Django management command (`seed_demo`) that loads a full
fictional dataset for a Russian IIoT vendor company «Северная Верфь» (products
«Вектор» and «Репер»): organization with logo, staff with access profiles,
product catalog with offers and prices, channels and AI agents, knowledge base
with file attachments, conversations, orders, external sales, support contracts
and a public help-center portal, calls, and notifications. It is idempotent and
never runs during `start.ps1`, `docker compose up`, or migrations.
The editable data lives in
`apps/backend/hub_platform/identity/demo_seed/data/` (one JSON manifest per
domain, plus `media/` for attachments). The command is baked into the backend
image, so the same dataset works for local testing and cloud installation.
Run it only after the local dev stack is running:
```powershell
.\scripts\seed-local.ps1
.\scripts\seed-demo.ps1
```
The data manifest and importer live in `dev/local-seed/`. The explicit
`local-seed` one-shot service is defined only by `compose.dev.yaml`, runs with
the local migration DB role, is excluded from the backend image, and the
importer refuses any environment other than `CUS_ENV=local`. Production compose
has no seed service or seed command.
Or directly via management command (dry-run by default, `--apply` to write):
Демо-контент портала FoxRay и отдельный анонимный Web-виджет поддержки можно
установить независимо от общего seed:
```powershell
.\scripts\seed-support-portal.ps1
```
python manage.py seed_demo --apply
```
Этот portal-only импорт идемпотентен и не создаёт пользователей, не меняет
пароли, роли, сессии или права доступа. Он публикует локальный портал
`http://foxray.localhost/`, категории и статьи, а также подключает существующий
Web Chat через отдельный канал `foxray-help`.
The `demo-seed` one-shot service is defined only by `compose.dev.yaml`. Outside
`DEBUG` (e.g. cloud installation) the command requires `--force`. Production
compose has no seed service.
## Tests
@@ -0,0 +1,11 @@
"""Демонстрационные данные для локального тестирования и установки облака.
Пакет читает редактируемые JSON-манифесты из :mod:`demo_seed.data` и через
:func:`hub_platform.identity.demo_seed.orchestrator.run_demo_seed` создаёт
полный набор выдуманных данных организации «Северная Верфь». Идемпотентен:
повторный запуск не дублирует и не затирает существующие записи.
"""
from hub_platform.identity.demo_seed.orchestrator import run_demo_seed
__all__ = ["run_demo_seed"]
@@ -0,0 +1,101 @@
{
"schemaVersion": 1,
"products": [
{
"code": "vektor",
"name": "Вектор",
"siteUrl": "https://vektor.severnayaverf.ru",
"departments": ["sales", "support"]
},
{
"code": "reper",
"name": "Репер",
"siteUrl": "https://reper.severnayaverf.ru",
"departments": ["sales", "support"]
}
],
"offers": [
{
"key": "vektor-box",
"product": "vektor",
"code": "vektor-box",
"name": "Вектор — коробочная лицензия",
"description": "Постоянная лицензия на сервер SCADA-мониторинга оборудования для одного производственного участка. Развёртывается на площадке заказчика.",
"fulfillmentType": "BOX_LICENSE",
"paymentType": "ONE_TIME",
"fiscalName": "Лицензия на ПО «Вектор» (коробочная)",
"aiOfferable": true
},
{
"key": "vektor-support",
"product": "vektor",
"code": "vektor-support-12m",
"name": "Вектор — продление поддержки на 12 месяцев",
"description": "Доступ к обновлениям и технической поддержке коробочной лицензии Вектор. Привязывается к основной лицензии.",
"fulfillmentType": "SUPPORT_EXTENSION",
"paymentType": "SUBSCRIPTION",
"primaryBoxOffer": "vektor-box",
"fiscalName": "Продление поддержки «Вектор» (12 мес.)",
"aiOfferable": false
},
{
"key": "reper-cloud",
"product": "reper",
"code": "reper-cloud",
"name": "Репер — облачная подписка",
"description": "Доступ к платформе телематики транспорта и спецтехники в облаке. Оплата за парк техники помесячно.",
"fulfillmentType": "SAAS_ACCESS",
"paymentType": "SUBSCRIPTION",
"fiscalName": "Подписка на сервис «Репер»",
"aiOfferable": true
}
],
"prices": [
{
"key": "vektor-box-price",
"offer": "vektor-box",
"billingPeriod": "ONE_TIME",
"amountMinor": 1290000,
"currency": "RUB"
},
{
"key": "vektor-support-price",
"offer": "vektor-support",
"billingPeriod": "YEAR",
"amountMinor": 189000,
"currency": "RUB"
},
{
"key": "reper-cloud-month",
"offer": "reper-cloud",
"billingPeriod": "MONTH",
"amountMinor": 24000,
"currency": "RUB"
},
{
"key": "reper-cloud-year",
"offer": "reper-cloud",
"billingPeriod": "YEAR",
"amountMinor": 264000,
"currency": "RUB"
}
],
"salesSources": [
{
"key": "vektor-source",
"product": "vektor",
"code": "vektor-product-api",
"environment": "PRODUCTION",
"status": "ACTIVE",
"credentialHint": "demo-vektor-2026"
},
{
"key": "reper-source",
"product": "reper",
"code": "reper-product-api",
"environment": "PRODUCTION",
"status": "ACTIVE",
"credentialHint": "demo-reper-2026"
}
]
}
@@ -0,0 +1,225 @@
{
"schemaVersion": 1,
"integrations": [
{ "key": "llm-provider", "provider": "OPENROUTER", "name": "OpenRouter (CustoAI)", "kind": "LLM_PROVIDER", "channel": null, "status": "OK" },
{ "key": "site-max", "provider": "MAX", "name": "Сайт — MAX", "channel": "site-main", "status": "OK" },
{ "key": "site-tg", "provider": "TELEGRAM", "name": "Сайт — Telegram", "channel": "site-main", "status": "OK" },
{ "key": "site-web", "provider": "WEB", "name": "Сайт — веб-чат", "channel": "site-main", "status": "OK" },
{ "key": "vektor-sales-tg", "provider": "TELEGRAM", "name": "Вектор продажи — Telegram", "channel": "vektor-sales", "status": "OK" },
{ "key": "vektor-sales-web", "provider": "WEB", "name": "Вектор продажи — веб-чат", "channel": "vektor-sales", "status": "OK" },
{ "key": "reper-sales-tg", "provider": "TELEGRAM", "name": "Репер продажи — Telegram", "channel": "reper-sales", "status": "OK" },
{ "key": "reper-sales-max", "provider": "MAX", "name": "Репер продажи — MAX", "channel": "reper-sales", "status": "UNCHECKED" },
{ "key": "vektor-support-web", "provider": "WEB", "name": "Вектор поддержка — веб-чат", "channel": "vektor-support", "status": "OK" },
{ "key": "reper-support-web", "provider": "WEB", "name": "Репер поддержка — веб-чат", "channel": "reper-support", "status": "OK" },
{ "key": "partners-tg", "provider": "TELEGRAM", "name": "Партнёрская линия — Telegram", "channel": "partners", "status": "OK" },
{
"key": "support-email",
"provider": "EMAIL",
"name": "Почта поддержки",
"channel": "site-main",
"status": "OK",
"config": {
"imap_host": "imap.yandex.ru",
"imap_port": 993,
"imap_ssl": true,
"smtp_host": "smtp.yandex.ru",
"smtp_port": 465,
"smtp_ssl": true,
"email": "support@severnayaverf.ru"
}
}
],
"channels": [
{
"code": "site-main",
"name": "Северная Верфь — главный сайт",
"department": "sales",
"product": null,
"providerIntegration": "llm-provider",
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": false,
"allow_checkout_actions": false
}
},
{
"code": "vektor-sales",
"name": "Вектор — продажи",
"department": "sales",
"product": "vektor",
"providerIntegration": "llm-provider",
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": true,
"allow_checkout_actions": true
}
},
{
"code": "reper-sales",
"name": "Репер — продажи",
"department": "sales",
"product": "reper",
"providerIntegration": "llm-provider",
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": true,
"allow_checkout_actions": true
}
},
{
"code": "vektor-support",
"name": "Вектор — поддержка",
"department": "support",
"product": "vektor",
"policy": {
"requires_authenticated_product_identity": true,
"allow_anonymous_sessions": false,
"allow_self_reported_contact": false,
"allow_sales_attribution": false,
"allow_checkout_actions": false
}
},
{
"code": "reper-support",
"name": "Репер — поддержка",
"department": "support",
"product": "reper",
"policy": {
"requires_authenticated_product_identity": true,
"allow_anonymous_sessions": false,
"allow_self_reported_contact": false,
"allow_sales_attribution": false,
"allow_checkout_actions": false
}
},
{
"code": "partners",
"name": "Партнёрская линия",
"department": null,
"product": null,
"isActive": false,
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": false,
"allow_checkout_actions": false
}
},
{
"code": "reper-help-widget",
"name": "Репер — виджет Help-центра",
"department": "support",
"product": "reper",
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": false,
"allow_checkout_actions": false
}
}
],
"agents": [
{
"channel": "site-main",
"name": "Главный ассистент сайта",
"model": "anthropic/claude-sonnet-4.6",
"status": "ACTIVE",
"persona": "Виртуальный ассистент компании «Северная Верфь» — производителя промышленной телеметрии. Помогает посетителям сайта сориентироваться в продуктах Вектор и Репер.",
"tone": "Дружелюбный, деловой, без излишней фамильярности. Обращается на «вы».",
"instructions": "Если вопрос касается конкретного продукта или условий покупки — переключай диалог на менеджера продаж. Не обещай скидки и сроки, которых нет в базе знаний.",
"knowledge": ["vektor-overview", "reper-overview"]
},
{
"channel": "vektor-sales",
"name": "Менеджер продаж Вектора",
"model": "anthropic/claude-sonnet-4.6",
"status": "ACTIVE",
"persona": "Отвечает за продажи коробочной лицензии Вектор производственным предприятиям.",
"tone": "Деловой, экспертный, по сути.",
"instructions": "Уточняй масштаб производства и число участков — от этого зависит конфигурация. Предлагай продление поддержки как сопутствующую услугу.",
"knowledge": ["vektor-overview", "vektor-deploy"]
},
{
"channel": "reper-sales",
"name": "Менеджер продаж Репера",
"model": "anthropic/claude-sonnet-4.6",
"status": "ACTIVE",
"persona": "Отвечает за продажи облачной подписки на телематику Репер.",
"tone": "Деловой, ориентируется на выгоду клиента.",
"instructions": "Уточняй размер парка техники и типы транспорта. Для парков от 50 единиц предлагай годовую оплату — она выгоднее помесячной.",
"knowledge": ["reper-overview", "reper-telematics"]
}
],
"knowledge": [
{
"key": "vektor-overview",
"title": "Вектор — обзор продукта",
"description": "Краткое описание SCADA-системы Вектор для предпродажных консультаций.",
"content": "Вектор — сервер SCADA-мониторинга промышленного оборудования. Разворачивается на площадке заказчика, поставляется коробочной лицензией на один производственный участок. Поддерживает опрос контроллеров по OPC UA и Modbus, сбор метрик в реальном времени, алёртинг при выходе параметров за допуск.",
"visibility": "ORGANIZATION"
},
{
"key": "vektor-deploy",
"title": "Вектор — руководство по развёртыванию",
"description": "Требования к серверу и шаги развёртывания коробочной лицензии. Приложен файл с детальным руководством.",
"content": "Подробное руководство по развёртыванию коробочной лицензии Вектор приведено во вложении. Требуется сервер с ОС Linux, 8 ГБ ОЗУ, доступ к контроллерам по сети.",
"visibility": "DEPARTMENTS",
"attachments": ["vektor-deploy-guide.md"]
},
{
"key": "reper-overview",
"title": "Репер — обзор продукта",
"description": "Краткое описание телематики транспорта для предпродажных консультаций.",
"content": "Репер — облачная платформа телематики для транспорта и спецтехники. Сбор данных с бортовых контроллеров, контроль топлива, моточасов, геозон. Оплата по подписке за единицу техники.",
"visibility": "ORGANIZATION"
},
{
"key": "reper-telematics",
"title": "Репер — спецификация телематики",
"description": "Поддерживаемые протоколы и бортовые контроллеры. Приложен файл со спецификацией.",
"content": "Поддерживаемые протоколы обмена с бортовыми контроллерами и список совместимого оборудования приведены во вложении.",
"visibility": "DEPARTMENTS",
"attachments": ["reper-telematics-spec.md"]
}
],
"llmInvocations": [
{
"channel": "vektor-sales",
"product": "vektor",
"purpose": "demo-chat-sales",
"model": "anthropic/claude-sonnet-4.6",
"promptTokens": 5200,
"completionTokens": 1380,
"costMicros": 312000,
"status": "SUCCESS"
},
{
"channel": "reper-sales",
"product": "reper",
"purpose": "demo-chat-sales",
"model": "anthropic/claude-sonnet-4.6",
"promptTokens": 4100,
"completionTokens": 980,
"costMicros": 198000,
"status": "SUCCESS"
},
{
"channel": "vektor-sales",
"product": "vektor",
"purpose": "demo-chat-sales",
"model": "anthropic/claude-sonnet-4.6",
"promptTokens": 3300,
"completionTokens": 420,
"costMicros": 88000,
"status": "BLOCKED"
}
]
}
@@ -0,0 +1,123 @@
{
"schemaVersion": 1,
"orders": [
{
"externalId": "demo-ord-001",
"product": "vektor",
"contact": "e.zaitseva",
"conversation": "vektor-closed-deal",
"channel": "vektor-sales",
"paymentStatus": "PAID",
"fulfillmentStatus": "DELIVERED",
"amountMinor": 1479000,
"items": [
{ "offer": "vektor-box", "price": "vektor-box-price", "title": "Вектор — коробочная лицензия", "quantity": 1, "amountMinor": 1290000 },
{ "offer": "vektor-support", "price": "vektor-support-price", "title": "Продление поддержки 12 мес.", "quantity": 1, "amountMinor": 189000 }
]
},
{
"externalId": "demo-ord-002",
"product": "reper",
"contact": "p.gusev",
"conversation": "reper-lead-active",
"channel": "reper-sales",
"paymentStatus": "PENDING",
"fulfillmentStatus": "PENDING",
"amountMinor": 264000,
"items": [
{ "offer": "reper-cloud", "price": "reper-cloud-year", "title": "Репер — подписка на 12 мес. (60 ед.)", "quantity": 1, "amountMinor": 264000 }
]
},
{
"externalId": "demo-ord-003",
"product": "reper",
"contact": "v.lebedeva",
"conversation": "reper-quote",
"channel": "reper-sales",
"paymentStatus": "PAID",
"fulfillmentStatus": "FAILED",
"amountMinor": 140000,
"items": [
{ "offer": "reper-cloud", "price": "reper-cloud-month", "title": "Репер — подписка (32 ед.)", "quantity": 1, "amountMinor": 140000 }
]
},
{
"externalId": "demo-ord-004",
"product": "vektor",
"contact": "n.kuznetsova",
"conversation": "vektor-lead-active",
"channel": "vektor-sales",
"paymentStatus": "PENDING",
"fulfillmentStatus": "PENDING",
"amountMinor": 1290000,
"items": [
{ "offer": "vektor-box", "price": "vektor-box-price", "title": "Вектор — коробочная лицензия", "quantity": 1, "amountMinor": 1290000 }
]
}
],
"sales": [
{
"salesSource": "vektor-source",
"eventId": "demo-sale-evt-001",
"eventType": "sale.confirmed",
"externalSaleId": "demo-sale-001",
"amountMinor": 1290000,
"currency": "RUB",
"daysAgo": 25,
"items": [
{ "name": "Вектор — коробочная лицензия", "quantity": 1, "amount_minor": 1290000 }
]
},
{
"salesSource": "reper-source",
"eventId": "demo-sale-evt-002",
"eventType": "sale.confirmed",
"externalSaleId": "demo-sale-002",
"amountMinor": 264000,
"currency": "RUB",
"daysAgo": 9,
"items": [
{ "name": "Репер — подписка на 12 мес.", "quantity": 1, "amount_minor": 264000 }
]
},
{
"salesSource": "reper-source",
"eventId": "demo-sale-evt-003",
"eventType": "sale.partially_refunded",
"externalSaleId": "demo-sale-002",
"amountMinor": 264000,
"refundedAmountMinor": 40000,
"currency": "RUB",
"daysAgo": 2,
"metadata": { "reason": "Списание за неиспользуемые единицы по обращению клиента" }
}
],
"externalCustomers": [
{ "product": "vektor", "externalCustomerId": "demo-vektor-cust-001", "contact": "e.zaitseva" },
{ "product": "reper", "externalCustomerId": "demo-reper-cust-001", "contact": "p.gusev" }
],
"attributionTokens": [
{
"product": "vektor",
"offer": "vektor-box",
"contact": "n.kuznetsova",
"conversation": "vektor-lead-active",
"channel": "vektor-sales",
"connection": "vektor-sales-tg",
"actorType": "AI_AGENT",
"actorId": "vektor-sales-agent",
"ttlDays": 30
},
{
"product": "reper",
"offer": "reper-cloud",
"contact": "p.gusev",
"conversation": "reper-lead-active",
"channel": "reper-sales",
"connection": "reper-sales-tg",
"actorType": "AI_AGENT",
"actorId": "reper-sales-agent",
"ttlDays": 30
}
]
}
@@ -0,0 +1,210 @@
{
"schemaVersion": 1,
"contacts": [
{ "key": "n.kuznetsova", "name": "Наталья Кузнецова", "phone": "+7 812 445 90 12" },
{ "key": "s.orlov", "name": "Сергей Орлов", "phone": "+7 343 261 04 77", "avatarUrl": "https://i.pravatar.cc/200?img=12" },
{ "key": "v.lebedeva", "name": "Виктория Лебедева", "phone": "+7 846 230 58 90", "avatarUrl": "https://i.pravatar.cc/200?img=5" },
{ "key": "p.gusev", "name": "Павел Гусев", "phone": "" },
{ "key": "o.frolova", "name": "Ольга Фролова", "phone": "+7 495 120 33 18" },
{ "key": "a.makarov", "name": "Антон Макаров", "phone": "+7 861 200 41 55" },
{ "key": "e.zaitseva", "name": "Екатерина Зайцева", "phone": "+7 831 410 76 22" },
{ "key": "r.borisov", "name": "Роман Борисов", "phone": "+7 846 300 88 04" },
{ "key": "f.grigorev", "name": "Фёдор Григорьев", "phone": "+7 495 778 12 09" }
],
"connectionIdentities": [
{ "key": "n.kuznetsova-tg", "contact": "n.kuznetsova", "connection": "vektor-sales-tg", "externalUserId": "demo-nkuznetsova", "displayName": "Наталья Кузнецова", "username": "nkuznetsova_plant" },
{ "key": "s.orlov-tg", "contact": "s.orlov", "connection": "vektor-sales-tg", "externalUserId": "demo-sorlov", "displayName": "Сергей Орлов", "username": "sorlov_ural" },
{ "key": "v.lebedeva-max", "contact": "v.lebedeva", "connection": "reper-sales-max", "externalUserId": "demo-vlebedeva", "displayName": "Виктория Лебедева", "username": "vlebedeva" },
{ "key": "p.gusev-tg", "contact": "p.gusev", "connection": "reper-sales-tg", "externalUserId": "demo-pgusev", "displayName": "Павел Гусев", "username": "pgusev_logistics" },
{ "key": "o.frolova-web", "contact": "o.frolova", "connection": "site-web", "externalUserId": "demo-ofrolova", "displayName": "Ольга Фролова" },
{ "key": "a.makarov-tg", "contact": "a.makarov", "connection": "site-tg", "externalUserId": "demo-amakarov", "displayName": "Антон Макаров", "username": "amakarov_int" },
{ "key": "r.borisov-max", "contact": "r.borisov", "connection": "site-max", "externalUserId": "demo-rborisov", "displayName": "Роман Борисов", "username": "rborisov" },
{ "key": "e.zaitseva-tg", "contact": "e.zaitseva", "connection": "vektor-sales-tg", "externalUserId": "demo-ezaitseva", "displayName": "Екатерина Зайцева", "username": "ezaitseva_nnp" },
{ "key": "f.grigorev-email", "contact": "f.grigorev", "connection": "support-email", "externalUserId": "f.grigorev@technosoft.ru", "displayName": "Фёдор Григорьев", "username": "f.grigorev@technosoft.ru" }
],
"conversations": [
{
"key": "vektor-lead-active",
"channel": "vektor-sales",
"connection": "vektor-sales-tg",
"contact": "n.kuznetsova",
"externalChatId": "demo-vektor-lead-active",
"controlMode": "AI",
"expectedResponder": "AI",
"lastActivityMinutesAgo": 3,
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Добрый день! Нас интересует мониторинг оборудования для литейного цеха. Что по Вектору?" },
{ "externalId": "m2", "author": "AI", "text": "Здравствуйте, Наталья! Вектор хорошо подходит для цехов с разнородными контроллерами — опрашивает их по OPC UA и Modbus. Подскажу конфигурацию под ваш масштаб." }
]
},
{
"key": "vektor-queue",
"channel": "vektor-sales",
"connection": "vektor-sales-tg",
"contact": "s.orlov",
"externalChatId": "demo-vektor-queue",
"controlMode": "PAUSED",
"expectedResponder": "OPERATOR",
"lastActivityMinutesAgo": 14,
"assignedOperator": "sales_lead",
"readBy": "sales_lead",
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Нужен расчёт лицензий на 3 производственных участка. Скидка за объём есть?" }
]
},
{
"key": "vektor-closed-deal",
"channel": "vektor-sales",
"connection": "vektor-sales-tg",
"contact": "e.zaitseva",
"externalChatId": "demo-vektor-closed-deal",
"controlMode": "HUMAN",
"expectedResponder": "NOBODY",
"lifecycle": "CLOSED",
"assignedOperator": "sales_lead",
"readBy": "sales_lead",
"lastActivityMinutesAgo": 1800,
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Добрый день! Берём Вектор на основной цех плюс продление поддержки." },
{ "externalId": "m2", "author": "OPERATOR", "authorUser": "sales_lead", "text": "Отлично, Екатерина! Готовлю счёт на лицензию и год поддержки. Пришлю на почту сегодня." },
{ "externalId": "m3", "author": "CONTACT", "text": "Спасибо, жду." }
]
},
{
"key": "reper-lead-active",
"channel": "reper-sales",
"connection": "reper-sales-tg",
"contact": "p.gusev",
"externalChatId": "demo-reper-lead-active",
"controlMode": "AI",
"expectedResponder": "CUSTOMER",
"lastActivityMinutesAgo": 8,
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Здравствуйте! У нас автопарк 60 машин, интересна телематика Репер." },
{ "externalId": "m2", "author": "AI", "text": "Здравствуйте, Павел! Для 60 единиц выгоднее годовая подписка. Уточните, пожалуйста, типы транспорта — есть ли спецтехника?" }
]
},
{
"key": "reper-quote",
"channel": "reper-sales",
"connection": "reper-sales-max",
"contact": "v.lebedeva",
"externalChatId": "demo-reper-quote",
"controlMode": "PAUSED",
"expectedResponder": "OPERATOR",
"lastActivityMinutesAgo": 22,
"assignedOperator": "sales_junior",
"readBy": "sales_junior",
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Пришлите КП на телематику для парка спецтехники, 32 единицы." }
]
},
{
"key": "site-general",
"channel": "site-main",
"connection": "site-web",
"contact": "o.frolova",
"externalChatId": "demo-site-general",
"controlMode": "AI",
"expectedResponder": "AI",
"lastActivityMinutesAgo": 1,
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Здравствуйте! Не пойму, какой продукт мне подходит — у меня небольшой завод." },
{ "externalId": "m2", "author": "AI", "text": "Здравствуйте! Подскажу. Если нужно следить за оборудованием в цеху — это Вектор. Если техника и автопарк — Репер. Расскажите чуть подробнее о задаче?" }
]
},
{
"key": "site-partner",
"channel": "site-main",
"connection": "site-tg",
"contact": "a.makarov",
"externalChatId": "demo-site-partner",
"controlMode": "HUMAN",
"expectedResponder": "OPERATOR",
"lifecycle": "OPEN",
"lastActivityMinutesAgo": 35,
"assignedOperator": "admin",
"readBy": "admin",
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Мы системный интегратор, обсуждаем партнёрство по вашим продуктам." },
{ "externalId": "m2", "author": "OPERATOR", "authorUser": "admin", "text": "Антон, спасибо за интерес! Подключу вас к партнёрской программе, отправлю условия на почту." }
]
},
{
"key": "site-spam",
"channel": "site-main",
"connection": "site-max",
"contact": "r.borisov",
"externalChatId": "demo-site-spam",
"controlMode": "AI",
"expectedResponder": "NOBODY",
"lifecycle": "SPAM",
"lastActivityMinutesAgo": 120,
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Куплю ваш бизнес, пишите на этот номер" }
]
},
{
"key": "reper-support-identity",
"channel": "reper-support",
"connection": "reper-support-web",
"contact": "p.gusev",
"supportIdentitySnapshot": "reper-p-gusev",
"externalChatId": "demo-reper-support-identity",
"controlMode": "HUMAN",
"expectedResponder": "OPERATOR",
"lastActivityMinutesAgo": 6,
"assignedOperator": "support_lead",
"readBy": "support_lead",
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "Не отображается топливный датчик на одной из машин." },
{ "externalId": "m2", "author": "OPERATOR", "authorUser": "support_lead", "text": "Проверяю статус опроса контроллера. Какой госномер машины?" },
{ "externalId": "m3", "author": "CONTACT", "text": "В 477 ОР 63" }
]
},
{
"key": "vektor-support-identity",
"channel": "vektor-support",
"connection": "vektor-support-web",
"contact": "n.kuznetsova",
"supportIdentitySnapshot": "vektor-n-kuznetsova",
"externalChatId": "demo-vektor-support-identity",
"controlMode": "AI",
"expectedResponder": "AI",
"lifecycle": "OPEN",
"lastActivityMinutesAgo": 11,
"messages": [
{ "externalId": "m1", "author": "CONTACT", "text": "После обновления Вектора пропали тревоги по давлению." },
{ "externalId": "m2", "author": "AI", "text": "Проверю настройки порогов после обновления. Уточните версию сборки в правом нижнем углу консоли." }
]
},
{
"key": "support-email-thread",
"channel": "site-main",
"connection": "support-email",
"contact": "f.grigorev",
"externalChatId": "f.grigorev@technosoft.ru",
"controlMode": "HUMAN",
"expectedResponder": "OPERATOR",
"lifecycle": "OPEN",
"lastActivityMinutesAgo": 42,
"assignedOperator": "support_engineer",
"readBy": "support_engineer",
"transportMeta": { "subject": "Вопрос по продлению поддержки Вектор", "last_message_id": "<abc123.def456@technosoft.ru>" },
"messages": [
{
"externalId": "<abc123.def456@technosoft.ru>",
"author": "CONTACT",
"text": "Добрый день!\n\nПодскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.\n\nС уважением,\nФёдор Григорьев\nООО «ТехноСофт»",
"contentHtml": "<div><p>Добрый день!</p><p>Подскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.</p><p>С уважением,<br>Фёдор Григорьев<br>ООО «ТехноСофт»</p></div>"
},
{
"externalId": "demo-email-reply-1",
"author": "OPERATOR",
"authorUser": "support_engineer",
"text": "Здравствуйте, Фёдор! Продление поддержки на 12 месяцев для лицензии В-4712 — 189 000 ₽. Подготовлю счёт, на какую почту направить?"
}
]
}
]
}
@@ -0,0 +1,30 @@
# Репер — спецификация телематики
## Поддерживаемые протоколы
| Протокол | Назначение |
|---|---|
| Teltonika | Бортовые контроллеры серии FMC/FMB |
| Wialon IPS | Совместимые трекеры сторонних производителей |
| Galileosky | Контроллеры Galileosky v2/v3 |
## Совместимое оборудование
- **Бортовые контроллеры:** Teltonika FMB130/FMB140, Galileosky Base/Smart.
- **Датчики уровня топлива (ДУТ):** Омником, Эскорт, LLS.
- **Датчики моточасов:** входы дискретных и аналоговых датчиков.
## Частота опроса
- Положение: раз в 30 секунд при движении, раз в 5 минут на стоянке.
- Топливо: непрерывно, усреднение за минуту.
## Атрибутика единицы техники
- Госномер, модель, тип (грузовик / спецтехника / прицеп).
- Привязка к бортовому контроллеру по идентификатору (IMEI).
## Ограничения
- Минимальный интервал данных — 10 секунд.
- Один бортовой контроллер = одна единица техники в подписке.
@@ -0,0 +1,26 @@
# Вектор — руководство по развёртыванию коробочной лицензии
## Системные требования
- **ОС:** Linux (Ubuntu 22.04 LTS / РЕД ОС 7.3)
- **Процессор:** 4 ядра
- **ОЗУ:** 8 ГБ
- **Диск:** 80 ГБ SSD
- **Сеть:** доступ к контроллерам оборудования по OPC UA (4840) и Modbus TCP (502)
## Порядок установки
1. Разверните дистрибутив из архива `vektor-server-<version>.tar.gz`.
2. Укажите лицензионный ключ в `/opt/vektor/etc/license.conf`.
3. Запустите сервис: `systemctl start vektor-server`.
4. Откройте веб-консоль на `https://<сервер>:8443` и задайте пароль администратора.
## Подключение контроллеров
- OPC UA: добавьте endpoint в разделе «Источники → OPC UA».
- Modbus TCP: укажите IP и список регистров в «Источники → Modbus».
## Проверка
После подключения зайдите в «Живые данные» — метрики должны обновляться в течение секунды.
Если данных нет, проверьте сетевую связность и брандмауэр.
@@ -0,0 +1,76 @@
{
"schemaVersion": 1,
"calls": [
{
"conversation": "reper-support-identity",
"initiatedBy": "support_lead",
"contactIdentity": "p.gusev-tg",
"status": "ENDED",
"endedBy": "STAFF",
"minutesAgo": 30,
"durationSeconds": 240,
"deliveryConnection": "reper-support-web",
"metrics": { "connectionType": "DIRECT", "localCandidateType": "host", "remoteCandidateType": "srflx", "roundTripMs": 42 }
},
{
"conversation": "vektor-queue",
"initiatedBy": "sales_lead",
"contactIdentity": "s.orlov-tg",
"status": "DECLINED",
"minutesAgo": 90
}
],
"notifications": [
{
"type": "DIALOG_WAITING",
"level": "WARNING",
"title": "Диалог ожидает оператора",
"body": "Сергей Орлов ждёт ответа в канале продаж Вектора.",
"targetRoute": "conversations",
"targetId": "vektor-queue",
"audience": "OPERATORS",
"department": "sales",
"sourceId": "vektor-queue",
"readBy": "sales_lead"
},
{
"type": "PAYMENT_RECEIVED",
"level": "SUCCESS",
"title": "Платёж получен",
"body": "Оплачен заказ на подписку Репер для ТрансЛогистик.",
"targetRoute": "orders",
"targetId": "demo-ord-003",
"audience": "OWNER",
"sourceId": "demo-ord-003"
},
{
"type": "DIALOG_NEW_MESSAGE",
"level": "INFO",
"title": "Новое сообщение в диалоге",
"body": "Наталья Кузнецова написала в продажи Вектора.",
"targetRoute": "conversations",
"targetId": "vektor-lead-active",
"audience": "OPERATORS",
"department": "sales",
"sourceId": "vektor-lead-active"
},
{
"type": "INTEGRATION_ERROR",
"level": "CRITICAL",
"title": "Проблема с интеграцией",
"body": "Подключение Репер MAX не проверено. Данные могут не доходить.",
"audience": "OWNER",
"sourceId": "reper-sales-max"
}
],
"usage": {
"daysElapsed": 15,
"daysRemaining": 16,
"counters": [
{ "quota": "ai_agent_slots", "unit": "slots", "used": 3, "reserved": 0 },
{ "quota": "new_dialogs_per_period", "unit": "dialogs", "used": 47, "reserved": 0 },
{ "quota": "managed_ai_credits", "unit": "credits", "used": 1840, "reserved": 0 },
{ "quota": "storage_bytes", "unit": "bytes", "used": 5242880, "reserved": 0 }
]
}
}
@@ -0,0 +1,77 @@
{
"schemaVersion": 1,
"organization": {
"slug": "severnaya-verf",
"name": "Северная Верфь",
"timezone": "Europe/Moscow",
"currency": "RUB"
},
"aiAgentSlots": 5,
"accounts": [
{
"key": "owner",
"email": "i.volkov@severnayaverf.ru",
"password": "Demo-Owner-2026",
"fullName": "Игорь Волков",
"role": "OWNER",
"positionTitle": "Генеральный директор",
"phone": "+7 812 240 19 00",
"isStaff": true,
"isSuperuser": true
},
{
"key": "admin",
"email": "e.sokolova@severnayaverf.ru",
"password": "Demo-Admin-2026",
"fullName": "Елена Соколова",
"role": "ADMIN",
"positionTitle": "Руководитель клиентского сервиса",
"phone": "+7 812 240 19 01",
"isStaff": true
},
{
"key": "sales_lead",
"email": "d.morozov@severnayaverf.ru",
"password": "Demo-Sales-2026",
"fullName": "Дмитрий Морозов",
"role": "EMPLOYEE",
"positionTitle": "Старший менеджер по продажам",
"phone": "+7 921 330 11 45",
"department": "sales",
"systemProfile": "Sales operator"
},
{
"key": "sales_junior",
"email": "k.belaya@severnayaverf.ru",
"password": "Demo-Sales-2026",
"fullName": "Кристина Белая",
"role": "EMPLOYEE",
"positionTitle": "Менеджер по продажам",
"phone": "+7 921 778 02 19",
"department": "sales",
"systemProfile": "Sales operator"
},
{
"key": "support_lead",
"email": "a.novikov@severnayaverf.ru",
"password": "Demo-Support-2026",
"fullName": "Андрей Новиков",
"role": "EMPLOYEE",
"positionTitle": "Старший инженер поддержки",
"phone": "+7 812 240 19 50",
"department": "support",
"systemProfile": "Support operator"
},
{
"key": "support_engineer",
"email": "m.zhukova@severnayaverf.ru",
"password": "Demo-Support-2026",
"fullName": "Марина Жукова",
"role": "EMPLOYEE",
"positionTitle": "Инженер технической поддержки",
"phone": "+7 812 240 19 51",
"department": "support",
"systemProfile": "Support operator"
}
]
}
@@ -0,0 +1,105 @@
{
"schemaVersion": 1,
"contracts": [
{ "key": "vektor-contract", "product": "vektor", "code": "vektor.support.v1", "version": 1, "status": "ACTIVE" },
{ "key": "reper-contract", "product": "reper", "code": "reper.support.v1", "version": 1, "status": "ACTIVE" }
],
"identitySnapshots": [
{
"key": "vektor-n-kuznetsova",
"contract": "vektor-contract",
"subjectKey": "vektor-customer:nkuznetsova",
"accountKey": "plant-4712",
"displayName": "Наталья Кузнецова — ООО «Литейный завод №4»",
"displayEmail": "n.kuznetsova@lz4.ru",
"operatorContext": { "license_id": "V-4712", "site": "Литейный цех №4, Санкт-Петербург" },
"aiContext": { "product": "Вектор", "version": "3.4" },
"searchText": "Литейный завод 4 Наталья Кузнецова лицензия V-4712",
"issuedDaysAgo": 2,
"expiresInDays": 28
},
{
"key": "reper-p-gusev",
"contract": "reper-contract",
"subjectKey": "reper-customer:pgusev",
"accountKey": "fleet-0091",
"displayName": "Павел Гусев — ООО «ТрансЛогистик»",
"displayEmail": "p.gusev@translogistic.ru",
"operatorContext": { "fleet_id": "TL-0091", "units": 60 },
"aiContext": { "product": "Репер", "tariff": "year" },
"searchText": "ТрансЛогистик Павел Гусев автопарк 60",
"issuedDaysAgo": 5,
"expiresInDays": 25
}
],
"portal": {
"slug": "reper",
"name": "Репер — центр помощи",
"locale": "ru",
"status": "PUBLISHED",
"widgetChannel": "reper-help-widget",
"products": ["reper"],
"categories": [
{ "slug": "getting-started", "name": "Начало работы", "description": "Подключение техники и первые шаги в Репер", "sortOrder": 10 },
{ "slug": "devices", "name": "Оборудование и датчики", "description": "Совместимые бортовые контроллеры и датчики", "sortOrder": 20 },
{ "slug": "reports", "name": "Отчёты", "description": "Отчёты по топливу, моточасам и геозонам", "sortOrder": 30 },
{ "slug": "billing", "name": "Оплата и подписка", "description": "Тарифы, счета и продление подписки", "sortOrder": 40 }
],
"articles": [
{
"slug": "how-to-connect-first-unit",
"category": "getting-started",
"title": "Как подключить первую единицу техники",
"summary": "Пошаговая инструкция по добавлению транспортного средства в Репер.",
"content": "# Подключение первой единицы техники\n\n1. В разделе «Парк» нажмите «Добавить технику».\n2. Укажите госномер и модель.\n3. Выберите подключенный бортовой контроллер.\n4. Дождитесь первого пакета данных — он придёт в течение нескольких минут.\n\nЕсли данные не появляются дольше 15 минут, проверьте питание контроллера.",
"status": "PUBLISHED",
"helpfulVotes": 14
},
{
"slug": "supported-trackers",
"category": "devices",
"title": "Поддерживаемые бортовые контроллеры",
"summary": "Список совместимых контроллеров и протоколов обмена данными.",
"content": "# Поддерживаемые контроллеры\n\nРепер поддерживает устройства по протоколам: Teltonika, Wialon IPS, Galileosky.\n\nПолная спецификация протоколов приведена в базе знаний операторов поддержки.",
"status": "PUBLISHED",
"helpfulVotes": 8
},
{
"slug": "fuel-report",
"category": "reports",
"title": "Отчёт по расходу топлива",
"summary": "Как построить отчёт о заправках и сливах топлива.",
"content": "# Отчёт по топливу\n\nОткройте «Отчёты → Топливо», выберите период и технику. Отчёт покажет заправки, сливы и средний расход.\n\nДля корректных данных нужен исправный ДУТ.",
"status": "PUBLISHED",
"helpfulVotes": 11
},
{
"slug": "geozone-setup",
"category": "reports",
"title": "Настройка геозон",
"summary": "Создание геозон и контроль въезда/выезда.",
"content": "# Геозоны\n\nВ разделе «Геозоны» создайте область на карте. К ней можно привязать уведомления о въезде и выезде техники.",
"status": "PUBLISHED",
"helpfulVotes": 5
},
{
"slug": "tariffs-and-payment",
"category": "billing",
"title": "Тарифы и оплата подписки",
"summary": "Помесячная и годовая оплата, как получить счёт.",
"content": "# Тарифы и оплата\n\nДоступны два варианта оплаты: помесячно и за год. Годовая подписка выгоднее для парков от 50 единиц техники.\n\nСчёт на оплату можно запросить у вашего менеджера или в этом чате.",
"status": "PUBLISHED",
"helpfulVotes": 9
},
{
"slug": "change-fleet-size",
"category": "billing",
"title": "Изменение размера парка",
"summary": "Как добавить или убрать технику из подписки.",
"content": "# Изменение парка\n\nПодписка пересчитывается по числу активных единиц техники. Чтобы убрать технику — переведите её в статус «Неактивна».",
"status": "PUBLISHED",
"helpfulVotes": 3
}
]
}
}
@@ -0,0 +1,5 @@
"""Loaders демо-сида по доменам.
Каждый модуль экспортирует ``load(context, refs)`` — идемпотентно создаёт записи
своего домена и пополняет :class:`DemoRefs` для последующих шагов.
"""
@@ -0,0 +1,103 @@
"""Каталог: продукты, офферы, цены, связи отделов, источники продаж."""
from __future__ import annotations
from django.utils import timezone
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.products.models import (
Offer,
Price,
Product,
ProductDepartment,
ProductStatus,
)
from hub_platform.sales.models import Environment, SalesSource, SalesSourceStatus, SalesSourceType
from hub_platform.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("catalog")
organization = refs.organization
now = timezone.now()
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
for department_code in item.get("departments", []):
department = refs.departments.get(department_code)
if department is not None:
ProductDepartment.objects.get_or_create(product=product, department=department)
for item in data["offers"]:
_ensure_offer(refs, item)
for item in data["prices"]:
_ensure_price(refs, item, now)
for item in data.get("salesSources", []):
_ensure_sales_source(refs, item)
def _ensure_offer(refs: DemoRefs, item: dict) -> None:
product = refs.products[item["product"]]
primary_box = None
if item.get("primaryBoxOffer"):
primary_box = refs.offers[item["primaryBoxOffer"]]
offer, _ = Offer.objects.get_or_create(
product=product,
code=item["code"],
defaults={
"name": item["name"],
"description": item.get("description", ""),
"fulfillment_type": item["fulfillmentType"],
"payment_type": item["paymentType"],
"primary_box_offer": primary_box,
"fiscal_name": item.get("fiscalName", ""),
"is_active": True,
"ai_offerable": item.get("aiOfferable", False),
},
)
refs.offers[item["key"]] = offer
def _ensure_price(refs: DemoRefs, item: dict, now) -> None:
offer = refs.offers[item["offer"]]
valid_from = now
price, _ = Price.objects.get_or_create(
offer=offer,
currency=item.get("currency", "RUB"),
billing_period=item["billingPeriod"],
version=1,
defaults={
"amount_minor": item["amountMinor"],
"valid_from": valid_from,
"is_active": True,
},
)
refs.prices[item["key"]] = price
def _ensure_sales_source(refs: DemoRefs, item: dict) -> None:
product = refs.products[item["product"]]
source, _ = SalesSource.objects.get_or_create(
organization=refs.organization,
product=product,
code=item["code"],
defaults={
"type": SalesSourceType.PRODUCT_API,
"environment": item.get("environment", Environment.PRODUCTION),
"status": item.get("status", SalesSourceStatus.ACTIVE),
"credential_hint": item.get("credentialHint", ""),
},
)
refs.sales_sources[item["key"]] = source
@@ -0,0 +1,158 @@
"""Каналы, интеграции, AI-агенты, база знаний (со вложениями), учёт LLM."""
from __future__ import annotations
from django.core.files.base import ContentFile
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.models import (
AIAgent,
AIAgentStatus,
CredentialMode,
Knowledge,
KnowledgeAttachment,
LlmInvocation,
LlmInvocationStatus,
)
from hub_platform.channels.models import Channel
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus
from hub_platform.tenancy.context import TenantContext
from hub_platform.tenancy.storage_quota import finalize_storage, reserve_storage
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("channels_ai")
organization = refs.organization
for item in data["integrations"]:
channel = refs.channels.get(item.get("channel"))
integration, _ = Integration.objects.get_or_create(
organization=organization,
provider=item["provider"],
name=item["name"],
defaults={
"kind": item.get("kind", IntegrationKind.MESSENGER),
"channel": channel,
"status": item.get("status", IntegrationStatus.OK),
"config": item.get("config", {}),
},
)
refs.integrations[item["key"]] = integration
for item in data["channels"]:
_ensure_channel(refs, item)
for item in data.get("agents", []):
_ensure_agent(refs, item)
_ensure_knowledge(context, refs, data.get("knowledge", []))
for item in data.get("llmInvocations", []):
_ensure_llm_invocation(refs, item)
def _ensure_channel(refs: DemoRefs, item: dict) -> None:
channel, _ = Channel.objects.get_or_create(
organization=refs.organization,
code=item["code"],
defaults={
"name": item["name"],
"department": refs.departments.get(item.get("department")),
"product": refs.products.get(item.get("product")),
"provider_integration": refs.integrations.get(item.get("providerIntegration")),
"is_active": item.get("isActive", True),
**item.get("policy", {}),
},
)
refs.channels[item["code"]] = channel
def _ensure_agent(refs: DemoRefs, item: dict) -> None:
channel = refs.channels[item["channel"]]
agent, created = AIAgent.objects.get_or_create(
channel=channel,
defaults={
"name": item["name"],
"status": item.get("status", AIAgentStatus.ACTIVE),
"model": item.get("model", ""),
"credential_mode": item.get("credentialMode", CredentialMode.CUSTOAI),
"persona": item.get("persona", ""),
"tone": item.get("tone", ""),
"instructions": item.get("instructions", ""),
},
)
if created:
for knowledge_key in item.get("knowledge", []):
knowledge = refs.knowledge.get(knowledge_key)
if knowledge is not None:
agent.knowledge_items.add(knowledge)
def _ensure_knowledge(context: TenantContext, refs: DemoRefs, items: list[dict]) -> None:
organization = refs.organization
# Системная категория «Без категории» создаётся в foundation; хелпер
# идемпотентен, поэтому безопасно получить её здесь же.
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
category = ensure_uncategorized_category(organization)
for item in items:
knowledge, created = Knowledge.objects.get_or_create(
organization=organization,
title=item["title"],
defaults={
"category": category,
"description": item.get("description", ""),
"content": item.get("content", ""),
"visibility": item.get("visibility", KnowledgeVisibility.ORGANIZATION),
},
)
refs.knowledge[item["key"]] = knowledge
if created:
for attachment_key in item.get("attachments", []):
_attach_knowledge_file(context, refs, knowledge, attachment_key)
def _attach_knowledge_file(
context: TenantContext, refs: DemoRefs, knowledge: Knowledge, filename: str
) -> None:
if KnowledgeAttachment.objects.filter(knowledge=knowledge, original_name=filename).exists():
return
data = manifest.media_text(filename).encode("utf-8")
reservation_key = f"demo-knowledge:{knowledge.id}:{filename}"
reserve_storage(context=context, expected_bytes=len(data), idempotency_key=reservation_key)
suffix = filename.rsplit(".", 1)[-1].lower()
content_type = "text/markdown" if suffix == "md" else "application/octet-stream"
attachment = KnowledgeAttachment.objects.create(
organization=refs.organization,
knowledge=knowledge,
original_name=filename,
content_type=content_type,
size=len(data),
extracted_text=manifest.media_text(filename),
)
attachment.file.save(filename, ContentFile(data), save=True)
finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(data))
def _ensure_llm_invocation(refs: DemoRefs, item: dict) -> None:
channel = refs.channels[item["channel"]]
product = refs.products.get(item.get("product"))
prompt_tokens = item.get("promptTokens", 0)
completion_tokens = item.get("completionTokens", 0)
LlmInvocation.objects.get_or_create(
channel=channel,
purpose=item["purpose"],
operation=item.get("operation", "chat"),
model=item.get("model", ""),
defaults={
"product": product,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"cost_micros": item.get("costMicros", 0),
"status": item.get("status", LlmInvocationStatus.SUCCESS),
},
)
@@ -0,0 +1,128 @@
"""Коммерция: заказы, позиции, продажи (через application-сервис), атрибуция.
Продажи создаются через канонический ``record_product_sales_event`` (ADR-HUB-0025),
а не прямой ORM-write в Sale — это соблюдает append-only контракт журнала.
Заказы (Hub Order) создаются напрямую, как и в существующем сиде.
"""
from __future__ import annotations
from datetime import timedelta
from django.utils import timezone
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.orders.models import FulfillmentStatus, Order, OrderItem, PaymentStatus
from hub_platform.sales.models import (
ActorType,
AttributionToken,
Environment,
ExternalCustomerIdentity,
)
from hub_platform.sales.services import record_product_sales_event
from hub_platform.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("commerce")
now = timezone.now()
for item in data.get("orders", []):
_ensure_order(refs, item, now)
for item in data.get("sales", []):
_ensure_sale(context, refs, item)
for item in data.get("externalCustomers", []):
_ensure_external_customer(refs, item)
for item in data.get("attributionTokens", []):
_ensure_attribution_token(refs, item, now)
def _ensure_order(refs: DemoRefs, item: dict, now) -> None:
payment_status = item["paymentStatus"]
order, created = Order.objects.get_or_create(
organization=refs.organization,
source="demo-seed",
external_id=item["externalId"],
defaults={
"product": refs.products[item["product"]],
"contact": refs.contacts[item["contact"]],
"conversation": refs.conversations.get(item.get("conversation")),
"channel": refs.channels[item["channel"]],
"payment_status": payment_status,
"fulfillment_status": item.get("fulfillmentStatus", FulfillmentStatus.NONE),
"amount_minor": item["amountMinor"],
"paid_at": now if payment_status == PaymentStatus.PAID else None,
},
)
if created:
for line in item.get("items", []):
OrderItem.objects.get_or_create(
order=order,
offer=refs.offers[line["offer"]],
defaults={
"price": refs.prices.get(line.get("price")),
"title": line["title"],
"quantity": line.get("quantity", 1),
"amount_minor": line["amountMinor"],
},
)
def _ensure_sale(context: TenantContext, refs: DemoRefs, item: dict) -> None:
source = refs.sales_sources[item["salesSource"]]
occurred_at = timezone.now() - timedelta(days=item.get("daysAgo", 0))
sale_block = {
"external_sale_id": item["externalSaleId"],
"amount_minor": item["amountMinor"],
"refunded_amount_minor": item.get("refundedAmountMinor", 0),
"currency": item.get("currency", "RUB"),
"items": item.get("items", []),
}
payload = {
"schema_version": 1,
"event_id": item["eventId"],
"event_type": item["eventType"],
"occurred_at": occurred_at.isoformat(),
"sale": sale_block,
"metadata": item.get("metadata", {}),
}
record_product_sales_event(context=context, source=source, payload=payload)
def _ensure_external_customer(refs: DemoRefs, item: dict) -> None:
ExternalCustomerIdentity.objects.get_or_create(
organization=refs.organization,
product=refs.products[item["product"]],
external_customer_id=item["externalCustomerId"],
defaults={
"contact": refs.contacts[item["contact"]],
"environment": item.get("environment", Environment.PRODUCTION),
},
)
def _ensure_attribution_token(refs: DemoRefs, item: dict, now) -> None:
import hashlib
import secrets
token = secrets.token_urlsafe(24)
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
AttributionToken.objects.get_or_create(
token_hash=token_hash,
defaults={
"organization": refs.organization,
"product": refs.products[item["product"]],
"offer": refs.offers.get(item.get("offer")),
"contact": refs.contacts[item["contact"]],
"conversation": refs.conversations[item["conversation"]],
"channel": refs.channels.get(item.get("channel")),
"connection": refs.integrations.get(item.get("connection")),
"actor_type": item.get("actorType", ActorType.AI_AGENT),
"actor_id": item.get("actorId", ""),
"expires_at": now + timedelta(days=item.get("ttlDays", 30)),
},
)
@@ -0,0 +1,116 @@
"""Диалоги: контакты, идентичности подключений, диалоги, сообщения, прочтения."""
from __future__ import annotations
from datetime import timedelta
from django.utils import timezone
from hub_platform.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
ConversationRead,
ExpectedResponder,
LifecycleState,
Message,
MessageAuthor,
)
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("conversations")
organization = refs.organization
now = timezone.now()
for item in data["contacts"]:
contact, _ = Contact.objects.update_or_create(
organization=organization,
name=item["name"],
defaults={"phone": item.get("phone", ""), "avatar_url": item.get("avatarUrl", "")},
)
refs.contacts[item["key"]] = contact
for item in data["connectionIdentities"]:
_ensure_identity(refs, item)
for item in data["conversations"]:
_ensure_conversation(refs, item, now)
def _ensure_identity(refs: DemoRefs, item: dict) -> None:
connection = refs.integrations[item["connection"]]
contact = refs.contacts[item["contact"]]
identity, _ = ConnectionIdentity.objects.update_or_create(
connection=connection,
external_user_id=item["externalUserId"],
defaults={
"contact": contact,
"display_name": item.get("displayName", ""),
"username": item.get("username", ""),
},
)
refs.identities[item["key"]] = identity
def _ensure_conversation(refs: DemoRefs, item: dict, now) -> None:
organization = refs.organization
channel = refs.channels[item["channel"]]
contact = refs.contacts.get(item.get("contact"))
connection = refs.integrations.get(item.get("connection"))
snapshot = refs.identity_snapshots.get(item.get("supportIdentitySnapshot"))
is_human = item.get("controlMode") == ControlMode.HUMAN
operator = refs.users.get(item.get("assignedOperator")) if is_human else None
conversation, _ = Conversation.objects.get_or_create(
organization=organization,
channel=channel,
external_chat_id=item["externalChatId"],
defaults={
"connection": connection,
"contact": contact,
"support_identity_snapshot": snapshot,
"lifecycle": item.get("lifecycle", LifecycleState.OPEN),
"control_mode": item.get("controlMode", ControlMode.AI),
"expected_responder": item.get("expectedResponder", ExpectedResponder.AI),
"assigned_operator": operator,
"transport_meta": item.get("transportMeta", {}),
},
)
Conversation.objects.filter(pk=conversation.pk).update(
last_activity_at=now - timedelta(minutes=item.get("lastActivityMinutesAgo", 0))
)
conversation.refresh_from_db()
refs.conversations[item["key"]] = conversation
last_message_id = 0
for message in item.get("messages", []):
author_type = message["author"]
msg, _ = Message.objects.get_or_create(
conversation=conversation,
external_id=message["externalId"],
defaults={
"author_type": author_type,
"author_user": refs.users.get(message.get("authorUser"))
if author_type == MessageAuthor.OPERATOR
else None,
"kind": message.get("kind", ""),
"text": message.get("text", ""),
"content_html": message.get("contentHtml", ""),
},
)
last_message_id = max(last_message_id, msg.id)
reader_key = item.get("readBy")
if reader_key and last_message_id:
user = refs.users.get(reader_key)
if user is not None:
ConversationRead.objects.update_or_create(
conversation=conversation,
user=user,
defaults={"last_read_message_id": last_message_id},
)
@@ -0,0 +1,106 @@
"""Базовый слой: организация, отделы, пользователи, участия, доступ, аудит.
Использует существующие идемпотентные хелперы (``ensure_*``).
"""
from __future__ import annotations
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.identity.access_defaults import ensure_system_assignment
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.identity.models import (
Department,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.identity.system_departments import ensure_system_departments
from hub_platform.subscriptions.default_subscription import ensure_default_subscription
from hub_platform.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("organization")
org_data = data["organization"]
organization, created = Organization.objects.get_or_create(
slug=org_data["slug"],
defaults={
"name": org_data["name"],
"timezone": org_data["timezone"],
"currency": org_data["currency"],
},
)
refs.organization = organization
departments = ensure_system_departments(organization)
refs.departments.update(departments)
for item in data.get("extraDepartments", []):
dept, _ = Department.objects.get_or_create(
organization=organization,
code=item["code"],
defaults={"name": item["name"]},
)
refs.departments[item["code"]] = dept
ensure_uncategorized_category(organization)
slots = data.get("aiAgentSlots", 5)
refs.subscription = ensure_default_subscription(organization, quantity=slots)
_ensure_users_and_memberships(context, refs, data)
if created:
record_audit_event(
organization=organization,
actor=refs.users.get("owner"),
action="demo.organization_created",
object_type="Organization",
object_id=str(organization.public_id),
payload={"source": "demo-seed"},
)
def _ensure_users_and_memberships(context: TenantContext, refs: DemoRefs, data: dict) -> None:
organization = refs.organization
owner_membership = None
for item in data["accounts"]:
email = HumanUser.objects.normalize_email(item["email"])
user, user_created = HumanUser.objects.get_or_create(
email=email,
defaults={
"full_name": item["fullName"],
"is_staff": item.get("isStaff", False),
"is_superuser": item.get("isSuperuser", False),
},
)
if user_created:
user.set_password(item["password"])
user.save(update_fields=["password"])
refs.users[item["key"]] = user
role = item["role"]
membership, _ = OrganizationMembership.objects.get_or_create(
user=user,
organization=organization,
defaults={
"role": role,
"position_title": item.get("positionTitle", ""),
"phone": item.get("phone", ""),
"primary_department": refs.departments.get(item.get("department", "")),
},
)
refs.memberships[item["key"]] = membership
if role == EmployeeRole.OWNER:
owner_membership = membership
elif item.get("systemProfile") and owner_membership is not None:
ensure_system_assignment(
employee=membership,
assigned_by=owner_membership,
department=refs.departments[item["department"]],
profile_name=item["systemProfile"],
)
@@ -0,0 +1,178 @@
"""Операционные данные: звонки, уведомления, период учёта использования."""
from __future__ import annotations
from datetime import timedelta
from django.utils import timezone
from hub_platform.calls.models import (
CallConnectionType,
CallEndedBy,
CallMetric,
CallParticipant,
CallSession,
CallStatus,
ParticipantConnectionState,
ParticipantSide,
)
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.notifications.models import (
Notification,
NotificationAudience,
NotificationLevel,
NotificationRead,
)
from hub_platform.subscriptions.models import (
UsageCounter,
UsageEntryKind,
UsageLedgerEntry,
UsagePeriod,
UsagePeriodStatus,
)
from hub_platform.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("operations")
_ensure_calls(refs, data.get("calls", []))
_ensure_notifications(refs, data.get("notifications", []))
_ensure_usage_period(refs, data.get("usage"))
def _ensure_calls(refs: DemoRefs, items: list[dict]) -> None:
now = timezone.now()
for item in items:
conversation = refs.conversations[item["conversation"]]
initiator = refs.users[item["initiatedBy"]]
if (
CallSession.objects.filter(conversation=conversation)
.exclude(status__in=("ENDED", "DECLINED", "CANCELLED", "MISSED", "FAILED", "EXPIRED"))
.exists()
):
continue # незавершённый звонок уже есть — не трогаем
started = now - timedelta(minutes=item.get("minutesAgo", 10))
session, created = CallSession.objects.get_or_create(
conversation=conversation,
defaults={
"organization": refs.organization,
"initiated_by": initiator,
"status": item.get("status", CallStatus.ENDED),
"delivery_connection": refs.integrations.get(item.get("deliveryConnection")),
},
)
if not created:
continue
if session.status == CallStatus.ENDED:
accepted = started + timedelta(seconds=8)
session.accepted_at = accepted
session.connected_at = accepted
session.ended_at = started + timedelta(seconds=item.get("durationSeconds", 120))
session.ended_by = item.get("endedBy", CallEndedBy.STAFF)
call_fields = ["accepted_at", "connected_at", "ended_at", "ended_by", "updated_at"]
session.save(update_fields=call_fields)
_ensure_call_metric(session, item)
contact_identity = refs.identities.get(item.get("contactIdentity"))
if contact_identity is not None:
CallParticipant.objects.get_or_create(
call_session=session,
side=ParticipantSide.CUSTOMER,
defaults={
"connection_identity": contact_identity,
"joined_at": session.connected_at,
"left_at": session.ended_at,
"last_connection_state": ParticipantConnectionState.DISCONNECTED,
},
)
CallParticipant.objects.get_or_create(
call_session=session,
side=ParticipantSide.STAFF,
defaults={
"user": initiator,
"joined_at": session.connected_at,
"left_at": session.ended_at,
"last_connection_state": ParticipantConnectionState.DISCONNECTED,
},
)
def _ensure_call_metric(session: CallSession, item: dict) -> None:
metrics = item.get("metrics")
if not metrics:
return
CallMetric.objects.update_or_create(
call_session=session,
side=ParticipantSide.STAFF,
defaults={
"connection_type": metrics.get("connectionType", CallConnectionType.DIRECT),
"local_candidate_type": metrics.get("localCandidateType", "host"),
"remote_candidate_type": metrics.get("remoteCandidateType", "srflx"),
"round_trip_ms": metrics.get("roundTripMs"),
},
)
def _ensure_notifications(refs: DemoRefs, items: list[dict]) -> None:
for item in items:
dedup_key = f"demo:{item['type']}:{item.get('sourceId', '')}"
notification, created = Notification.objects.get_or_create(
organization=refs.organization,
dedup_key=dedup_key,
defaults={
"department": refs.departments.get(item.get("department")),
"type": item["type"],
"level": item.get("level", NotificationLevel.INFO),
"title": item["title"],
"body": item.get("body", ""),
"target_route": item.get("targetRoute", ""),
"target_id": item.get("targetId", ""),
"audience": item.get("audience", NotificationAudience.ALL),
"recipient_user": refs.users.get(item.get("recipientUser")),
},
)
if created and item.get("readBy"):
user = refs.users.get(item["readBy"])
if user is not None:
NotificationRead.objects.get_or_create(notification=notification, user=user)
def _ensure_usage_period(refs: DemoRefs, usage_data: dict | None) -> None:
if not usage_data:
return
subscription = refs.subscription
now = timezone.now()
starts_at = now - timedelta(days=usage_data.get("daysElapsed", 15))
ends_at = now + timedelta(days=usage_data.get("daysRemaining", 15))
period, _ = UsagePeriod.objects.get_or_create(
subscription=subscription,
status=UsagePeriodStatus.OPEN,
defaults={"starts_at": starts_at, "ends_at": ends_at},
)
from hub_platform.subscriptions.models import QuotaDefinition
for item in usage_data.get("counters", []):
quota, _ = QuotaDefinition.objects.get_or_create(
key=item["quota"],
defaults={"name": item["quota"].replace("_", " "), "unit": item.get("unit", "")},
)
counter, _ = UsageCounter.objects.update_or_create(
period=period,
quota_definition=quota,
defaults={"used_value": item.get("used", 0), "reserved_value": item.get("reserved", 0)},
)
UsageLedgerEntry.objects.get_or_create(
period=period,
quota_definition=quota,
idempotency_key=f"demo-usage:{period.id}:{item['quota']}",
defaults={
"kind": UsageEntryKind.OPENING_BALANCE,
"quantity": item.get("used", 0),
"unit": item.get("unit", ""),
"source": "demo-seed",
"rule_version": "demo-seed",
"occurred_at": starts_at,
},
)
@@ -0,0 +1,152 @@
"""Поддержка: контракты, снимки идентичности, портал, статьи и ревизии."""
from __future__ import annotations
from datetime import timedelta
from django.utils import timezone
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
from hub_platform.support_portals.content_services import (
create_article,
create_category,
ensure_portal_editable,
publish_revision,
record_feedback,
)
from hub_platform.support_portals.models import (
PortalCategory,
SupportPortal,
SupportPortalProduct,
)
from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
from hub_platform.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("support")
_ensure_contracts(refs, data.get("contracts", []))
_ensure_snapshots(refs, data.get("identitySnapshots", []))
_ensure_portal(context, refs, data.get("portal"))
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),
},
)
refs.support_contracts[item["key"]] = contract
def _ensure_snapshots(refs: DemoRefs, items: list[dict]) -> None:
now = timezone.now()
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": now - timedelta(days=item.get("issuedDaysAgo", 1)),
"token_expires_at": now + timedelta(days=item.get("expiresInDays", 30)),
},
)
refs.identity_snapshots[item["key"]] = snapshot
def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None) -> None:
if not portal_data:
return
organization = refs.organization
support_dept = refs.departments["support"]
portal, portal_created = SupportPortal.objects.get_or_create(
slug=portal_data["slug"],
defaults={
"organization": organization,
"department": support_dept,
"name": portal_data["name"],
"default_locale": portal_data.get("locale", "ru"),
"status": portal_data.get("status", PortalStatus.PUBLISHED),
"widget_channel": refs.channels.get(portal_data.get("widgetChannel")),
},
)
if portal_created and portal.status == PortalStatus.PUBLISHED:
portal.published_at = timezone.now()
portal.save(update_fields=["published_at"])
ensure_portal_editable(portal)
for item in portal_data.get("products", []):
product = refs.products[item]
support_channel = refs.channels.get(f"{item}-support")
SupportPortalProduct.objects.get_or_create(
portal=portal,
product=product,
defaults={"support_channel": support_channel},
)
categories: dict[str, PortalCategory] = {}
for item in portal_data.get("categories", []):
category = PortalCategory.objects.filter(portal=portal, slug=item["slug"]).first()
if category is None:
category = create_category(
context=context,
portal=portal,
data={
"slug": item["slug"],
"name": item["name"],
"description": item.get("description", ""),
"sortOrder": item.get("sortOrder", 0),
},
)
categories[item["slug"]] = category
for item in portal_data.get("articles", []):
_ensure_article(context, portal, categories, item)
def _ensure_article(
context: TenantContext,
portal: SupportPortal,
categories: dict[str, PortalCategory],
item: dict,
) -> None:
existing = portal.articles.filter(slug=item["slug"]).first()
if existing is not None and existing.revisions.exists():
return # статья уже создана — идемпотентность
data = {
"slug": item["slug"],
"categoryId": categories[item["category"]].id,
"locale": item.get("locale", portal.default_locale),
"title": item["title"],
"summary": item.get("summary", ""),
"content": item.get("content", ""),
}
article = create_article(context=context, portal=portal, data=data)
if item.get("status", ArticleStatus.PUBLISHED) == ArticleStatus.PUBLISHED:
latest_revision = article.revisions.order_by("-revision").first()
if latest_revision is not None:
publish_revision(article=article, revision_id=latest_revision.id)
for _ in range(item.get("helpfulVotes", 0)):
record_feedback(article, helpful=True)
@@ -0,0 +1,86 @@
"""Чтение и валидация демонстрационных манифестов из каталога ``data``.
Манифесты — обычный JSON в каталоге ``demo_seed/data/``, отдельный файл на домен.
Редактируются вручную; версия схемы проверяется при загрузке. Медиа-вложения
(логотип, файлы знаний) лежат в ``demo_seed/data/media/``.
"""
from __future__ import annotations
import json
from functools import cache
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
DATA_DIR = Path(__file__).resolve().parent / "data"
MEDIA_DIR = DATA_DIR / "media"
SCHEMA_VERSION = 1
#: Файлы манифестов по доменам (имя файла без расширения).
MANIFEST_FILES: tuple[str, ...] = (
"organization",
"catalog",
"channels_ai",
"conversations",
"commerce",
"support",
"operations",
)
class ManifestError(ImproperlyConfigured):
"""Нарушение схемы или целостности манифеста демо-данных."""
@cache
def load(name: str) -> dict | list:
"""Возвращает разобранный JSON-манифест ``name`` с проверкой схемы.
Кэшируется на процесс: повторные вызовы во время одного прогона сида
не перечитывают файл.
"""
path = DATA_DIR / f"{name}.json"
if not path.is_file():
raise ManifestError(f"Demo manifest not found: {path}")
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ManifestError(f"Demo manifest {path} is not valid JSON: {error}") from error
if name == "organization":
_validate_organization(payload)
else:
_validate_section(payload, name)
return payload
def media_path(filename: str) -> Path:
"""Абсолютный путь к файлу-вложению из каталога ``data/media``."""
path = MEDIA_DIR / filename
if not path.is_file():
raise ManifestError(f"Demo media file not found: {path}")
return path
def media_text(filename: str) -> str:
"""Текстовое содержимое вложения (для SVG/Markdown)."""
return media_path(filename).read_text(encoding="utf-8")
def _validate_organization(payload: dict) -> None:
if not isinstance(payload, dict):
raise ManifestError("organization manifest must be a JSON object")
if payload.get("schemaVersion") != SCHEMA_VERSION:
raise ManifestError(f"organization manifest schemaVersion must be {SCHEMA_VERSION}")
org = payload.get("organization")
if not isinstance(org, dict):
raise ManifestError("organization block is required")
for key in ("slug", "name", "timezone", "currency"):
if not org.get(key):
raise ManifestError(f"organization.{key} is required")
def _validate_section(payload: dict | list, name: str) -> None:
if not isinstance(payload, dict) or payload.get("schemaVersion") != SCHEMA_VERSION:
raise ManifestError(f"{name} manifest schemaVersion must be {SCHEMA_VERSION}")
@@ -0,0 +1,53 @@
"""Оркестратор демо-сида «Северная Верфь».
Создаёт полный, взаимосвязанный набор выдуманных данных одной организации,
будто система уже работает. Запускается строго в порядке FK-зависимостей:
foundation → catalog → channels/ai → conversations → commerce → support →
operations. Идемпотентен на каждом шаге.
"""
from __future__ import annotations
from hub_platform.identity.demo_seed.loaders import (
catalog as catalog_loader,
)
from hub_platform.identity.demo_seed.loaders import (
channels_ai as channels_ai_loader,
)
from hub_platform.identity.demo_seed.loaders import (
commerce as commerce_loader,
)
from hub_platform.identity.demo_seed.loaders import (
conversations as conversations_loader,
)
from hub_platform.identity.demo_seed.loaders import (
foundation as foundation_loader,
)
from hub_platform.identity.demo_seed.loaders import (
operations as operations_loader,
)
from hub_platform.identity.demo_seed.loaders import (
support as support_loader,
)
from hub_platform.identity.demo_seed.refs import DemoRefs
from hub_platform.tenancy.context import TenantContext
__all__ = ["DemoRefs", "run_demo_seed"]
def run_demo_seed(context: TenantContext, *, refs: DemoRefs | None = None) -> DemoRefs:
"""Запускает все loaders в порядке зависимостей.
Предполагается вызов внутри ``tenant_atomic(context)``: tenant-контекст
активирован, RLS и storage-guard работают.
"""
refs = refs or DemoRefs()
foundation_loader.load(context, refs)
catalog_loader.load(context, refs)
channels_ai_loader.load(context, refs)
conversations_loader.load(context, refs)
commerce_loader.load(context, refs)
support_loader.load(context, refs)
operations_loader.load(context, refs)
return refs
@@ -0,0 +1,36 @@
"""Разделяемое состояние между loaders демо-сида.
Вынесено в отдельный модуль, чтобы избежать циклического импорта: orchestrator
импортирует loaders, а loaders импортируют ``DemoRefs``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class DemoRefs:
"""Ключи → объекты моделей, создаваемые loaders.
Каждый loader вешает на ``refs`` созданные объекты, чтобы последующие
шаги ссылались на них по строковым ключам из манифестов (не по PK).
"""
organization: object | None = None
subscription: object | None = None
departments: 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)
offers: dict[str, object] = field(default_factory=dict)
prices: dict[str, object] = field(default_factory=dict)
sales_sources: dict[str, object] = field(default_factory=dict)
integrations: dict[str, object] = field(default_factory=dict)
channels: dict[str, object] = field(default_factory=dict)
knowledge: dict[str, object] = field(default_factory=dict)
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)
@@ -0,0 +1,90 @@
"""Загружает полный набор демонстрационных данных организации «Северная Верфь».
Данные — выдуманные (РФ-наполнение), полностью покрывают значимые домены
системы. Источник — редактируемые JSON-манифесты в
``hub_platform/identity/demo_seed/data/``.
Идемпотентен: повторный запуск не дублирует и не затирает записи. По умолчанию
работает в режиме dry-run (ничего не пишет); запуск с ``--apply`` применяет сид.
Вне режима отладки требует явного ``--force`` (например, при установке облака).
"""
from __future__ import annotations
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.orchestrator import run_demo_seed
from hub_platform.tenancy.context import TenantActorKind, TenantContext
from hub_platform.tenancy.database import tenant_atomic
class Command(BaseCommand):
help = "Seeds the demo organization «Северная Верфь» with full fictional data."
def add_arguments(self, parser) -> None:
parser.add_argument(
"--apply",
action="store_true",
help="Apply the seed. Without it, only a dry-run summary is printed.",
)
parser.add_argument(
"--force",
action="store_true",
help="Allow running outside DEBUG (e.g. cloud installation).",
)
def handle(self, *args: object, **options: object) -> None:
if not settings.DEBUG and not options["force"]:
raise CommandError(
"Demo seed is for local/cloud installation only. Pass --force to run outside DEBUG."
)
org_data = manifest.load("organization")["organization"]
if not options["apply"]:
self.stdout.write(
self.style.WARNING(
f"Dry-run: would seed demo organization «{org_data['name']}» "
f"({org_data['slug']}). Pass --apply to create the data."
)
)
return
context = TenantContext.for_resource(
_resolve_organization(org_data),
actor_kind=TenantActorKind.SYSTEM,
)
with tenant_atomic(context):
refs = run_demo_seed(context)
self.stdout.write(
self.style.SUCCESS(
"Demo seed applied: "
f"org={refs.organization.slug}; "
f"users={len(refs.users)}; "
f"products={len(refs.products)}; "
f"channels={len(refs.channels)}; "
f"conversations={len(refs.conversations)}"
)
)
def _resolve_organization(org_data: dict):
"""Возвращает организацию сида, создавая её, если нужно.
``run_demo_seed`` (foundation-загрузчик) идемпотентно донастроит её поля;
здесь нужна только оболочка, чтобы построить TenantContext до старта.
"""
from hub_platform.identity.models import Organization
organization, _ = Organization.objects.get_or_create(
slug=org_data["slug"],
defaults={
"name": org_data["name"],
"timezone": org_data["timezone"],
"currency": org_data["currency"],
},
)
return organization
@@ -0,0 +1,105 @@
from io import StringIO
from django.core.management import call_command
from django.test import TestCase, override_settings
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
from hub_platform.calls.models import CallSession
from hub_platform.conversations.models import Conversation, Message
from hub_platform.identity.demo_seed.orchestrator import DemoRefs, run_demo_seed
from hub_platform.identity.models import (
EmployeeRole,
HumanUser,
Organization,
)
from hub_platform.notifications.models import Notification
from hub_platform.orders.models import Order
from hub_platform.products.models import Offer, Price, Product
from hub_platform.sales.models import Sale, SaleEvent
from hub_platform.support.models import ProductSupportContract, SupportIdentitySnapshot
from hub_platform.support_portals.models import PortalArticle, SupportPortal
from hub_platform.tenancy.context import TenantActorKind, TenantContext
from hub_platform.tenancy.database import tenant_atomic
DEMO_SLUG = "severnaya-verf"
def _run_seed() -> DemoRefs:
organization = Organization.objects.get(slug=DEMO_SLUG)
context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
with tenant_atomic(context):
return run_demo_seed(context)
class SeedDemoTests(TestCase):
"""Покрывает создание, идемпотентность и dry-run демо-сида «Северная Верфь»."""
def setUp(self) -> None:
# Организация создаётся командой; здесь нужен только shell для контекста.
Organization.objects.create(name="Северная Верфь", slug=DEMO_SLUG)
def test_creates_full_dataset(self) -> None:
refs = _run_seed()
self.assertEqual(refs.organization.slug, DEMO_SLUG)
# Персонал: владелец, админ, 2 продажи, 2 поддержки.
self.assertEqual(refs.organization.memberships.count(), 6)
self.assertEqual(
refs.organization.memberships.filter(role=EmployeeRole.EMPLOYEE).count(), 4
)
# Продукты, офферы, цены.
self.assertEqual(Product.objects.filter(organization=refs.organization).count(), 2)
self.assertGreaterEqual(
Offer.objects.filter(product__organization=refs.organization).count(), 3
)
self.assertGreaterEqual(Price.objects.count(), 3)
# Каналы и AI-агенты.
self.assertGreaterEqual(refs.organization.channels.count(), 6)
self.assertGreaterEqual(AIAgent.objects.count(), 3)
# База знаний со вложениями.
self.assertGreaterEqual(Knowledge.objects.filter(organization=refs.organization).count(), 4)
self.assertGreaterEqual(KnowledgeAttachment.objects.count(), 2)
# Диалоги и сообщения.
conversations = Conversation.objects.filter(organization=refs.organization)
self.assertGreaterEqual(conversations.count(), 8)
self.assertGreaterEqual(Message.objects.count(), 10)
# Заказы и продажи.
self.assertGreaterEqual(Order.objects.filter(organization=refs.organization).count(), 3)
self.assertGreaterEqual(Sale.objects.count(), 1)
self.assertGreaterEqual(SaleEvent.objects.count(), 1)
# Поддержка: контракты, снимки, портал со статьями.
self.assertGreaterEqual(ProductSupportContract.objects.count(), 2)
self.assertGreaterEqual(SupportIdentitySnapshot.objects.count(), 2)
self.assertGreaterEqual(PortalArticle.objects.count(), 6)
self.assertEqual(SupportPortal.objects.count(), 1)
# Операции: звонки и уведомления.
self.assertGreaterEqual(CallSession.objects.count(), 1)
self.assertGreaterEqual(Notification.objects.count(), 3)
def test_idempotent_rerun(self) -> None:
_run_seed()
counts_before = {
"users": HumanUser.objects.filter(email__icontains="severnayaverf.ru").count(),
"products": Product.objects.filter(organization__slug=DEMO_SLUG).count(),
"conversations": Conversation.objects.filter(organization__slug=DEMO_SLUG).count(),
"messages": Message.objects.filter(conversation__organization__slug=DEMO_SLUG).count(),
"orders": Order.objects.filter(organization__slug=DEMO_SLUG).count(),
}
_run_seed()
counts_after = {
"users": HumanUser.objects.filter(email__icontains="severnayaverf.ru").count(),
"products": Product.objects.filter(organization__slug=DEMO_SLUG).count(),
"conversations": Conversation.objects.filter(organization__slug=DEMO_SLUG).count(),
"messages": Message.objects.filter(conversation__organization__slug=DEMO_SLUG).count(),
"orders": Order.objects.filter(organization__slug=DEMO_SLUG).count(),
}
self.assertEqual(counts_before, counts_after)
@override_settings(DEBUG=True)
def test_dry_run_creates_nothing(self) -> None:
before = Conversation.objects.filter(organization__slug=DEMO_SLUG).count()
out = StringIO()
call_command("seed_demo", stdout=out)
after = Conversation.objects.filter(organization__slug=DEMO_SLUG).count()
self.assertEqual(before, after)
self.assertIn("Dry-run", out.getvalue())
+5 -31
View File
@@ -119,51 +119,25 @@ services:
- /app/apps/web-chat/node_modules
# Явный одноразовый импорт демонстрационных данных. Сервис существует только
# в compose.dev.yaml, профилем не включается в обычный `up` и не попадает в
# canonical production compose или backend image.
local-seed:
profiles: ["local-seed"]
# в compose.dev.yaml, профилем не включается в обычный `up`. Данные живут в
# backend image (management-команда seed_demo), отдельный bind-mount не нужен.
demo-seed:
profiles: ["demo-seed"]
build:
context: .
dockerfile: apps/backend/Dockerfile
env_file:
- .env
command: ["python", "/opt/custocrm-local-seed/seed.py"]
command: ["python", "manage.py", "seed_demo", "--apply"]
working_dir: /app/apps/backend
environment:
CUS_DB_ROLE: migration
CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
CUSTOCRM_LOCAL_SEED: "1"
volumes:
- ./apps/backend:/app/apps/backend
- ./dev/local-seed:/opt/custocrm-local-seed:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Отдельный импорт демо-контента портала не создаёт пользователей и не
# изменяет пароли, роли, сессии или другие учётные данные.
support-portal-seed:
profiles: ["support-portal-seed"]
build:
context: .
dockerfile: apps/backend/Dockerfile
env_file:
- .env
command: ["python", "/opt/custocrm-local-seed/seed_support_portal.py"]
working_dir: /app/apps/backend
environment:
CUS_DB_ROLE: migration
CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
CUSTOCRM_LOCAL_SEED: "1"
CUS_HELP_BASE_DOMAIN: localhost
CUS_HELP_PUBLIC_SCHEME: http
CUS_HELP_PUBLIC_PORT: ""
volumes:
- ./apps/backend:/app/apps/backend
- ./dev/local-seed:/opt/custocrm-local-seed:ro
depends_on:
postgres:
condition: service_healthy
-202
View File
@@ -1,202 +0,0 @@
{
"schemaVersion": 1,
"organization": {
"slug": "edevs",
"name": "Edevs",
"timezone": "Europe/Moscow",
"currency": "RUB"
},
"accounts": {
"owner": {
"email": "owner@edevs.tech",
"password": "Owner-Local-2026",
"fullName": "Иван Петров",
"positionTitle": "Владелец"
},
"operator": {
"email": "a.kotova@edevs.tech",
"password": "Operator-Local-2026",
"fullName": "Анна Котова",
"positionTitle": "Оператор отдела продаж",
"phone": "+7 916 245 14 02"
}
},
"departments": [
{ "code": "sales", "name": "Продажи" },
{ "code": "support", "name": "Поддержка" }
],
"products": [
{ "code": "firepage", "name": "FirePage", "departments": ["sales", "support"] },
{ "code": "foxray", "name": "FoxRay", "departments": ["sales", "support"] }
],
"channels": [
{
"code": "edevs-main",
"name": "Edevs — главный сайт",
"department": "sales",
"product": null,
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": false,
"allow_checkout_actions": false
},
"agent": { "name": "Edevs — главный сайт Agent", "model": "gpt-5-mini" }
},
{
"code": "partners",
"name": "Партнёрская линия",
"department": null,
"product": null,
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": false,
"allow_checkout_actions": false
},
"agent": null
},
{
"code": "firepage-support",
"name": "FirePage — поддержка",
"department": "support",
"product": "firepage",
"policy": {
"requires_authenticated_product_identity": true,
"allow_anonymous_sessions": false,
"allow_self_reported_contact": false,
"allow_sales_attribution": false,
"allow_checkout_actions": false
},
"agent": { "name": "FirePage — поддержка Agent", "model": "gpt-5-mini" }
},
{
"code": "firepage-sales",
"name": "FirePage — продажи",
"department": "sales",
"product": "firepage",
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": true,
"allow_checkout_actions": true
},
"agent": { "name": "FirePage: продажи", "model": "gpt-5-mini" }
},
{
"code": "foxray-sales",
"name": "FoxRay",
"department": "sales",
"product": "foxray",
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": true,
"allow_checkout_actions": true
},
"agent": { "name": "FoxRay: продажи", "model": "gpt-5-mini" }
},
{
"code": "foxray-support",
"name": "FoxRay — поддержка",
"department": "support",
"product": "foxray",
"policy": {
"requires_authenticated_product_identity": true,
"allow_anonymous_sessions": false,
"allow_self_reported_contact": false,
"allow_sales_attribution": false,
"allow_checkout_actions": false
},
"agent": { "name": "FoxRay — поддержка Agent", "model": "gpt-5-mini" }
},
{
"code": "foxray-webinars",
"name": "FoxRay — вебинары",
"department": "sales",
"product": "foxray",
"isActive": false,
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true,
"allow_sales_attribution": true,
"allow_checkout_actions": false
},
"agent": null
}
],
"integrations": [
{ "key": "edevs-max", "provider": "MAX", "name": "Edevs MAX", "channel": "edevs-main", "status": "OK" },
{ "key": "edevs-tg", "provider": "TELEGRAM", "name": "Edevs Telegram", "channel": "edevs-main", "status": "OK" },
{ "key": "edevs-web", "provider": "WEB", "name": "Edevs Web", "channel": "edevs-main", "status": "OK" },
{ "key": "partners-tg", "provider": "TELEGRAM", "name": "Партнёрская линия Telegram", "channel": "partners", "status": "OK" },
{ "key": "firepage-tg", "provider": "TELEGRAM", "name": "FirePage Telegram", "channel": "firepage-support", "status": "OK" },
{ "key": "firepage-web", "provider": "WEB", "name": "FirePage Web", "channel": "firepage-sales", "status": "OK" },
{ "key": "foxray-tg", "provider": "TELEGRAM", "name": "FoxRay Telegram", "channel": "foxray-sales", "status": "OK" },
{ "key": "foxray-max", "provider": "MAX", "name": "FoxRay MAX", "channel": "foxray-sales", "status": "UNCHECKED" },
{ "key": "foxray-email", "provider": "EMAIL", "name": "FoxRay Email", "channel": "foxray-sales", "status": "UNCHECKED" },
{ "key": "foxray-support-web", "provider": "WEB", "name": "FoxRay Support Web", "channel": "foxray-support", "status": "OK" }
],
"contacts": [
{ "key": "maria", "name": "Мария Соколова", "phone": "+7 999 123 45 67", "connection": "foxray-tg", "externalUserId": "local-maria" },
{ "key": "alex", "name": "Алексей Воронов", "phone": "", "connection": "foxray-max", "externalUserId": "local-alex" },
{ "key": "olga", "name": "Ольга Миронова", "phone": "+7 901 555 11 22", "connection": "firepage-tg", "externalUserId": "local-olga" }
],
"conversations": [
{
"key": "foxray-ai",
"channel": "foxray-sales",
"connection": "foxray-tg",
"contact": "maria",
"externalChatId": "local-foxray-ai",
"controlMode": "AI",
"expectedResponder": "AI",
"lastActivityMinutesAgo": 4,
"messages": [
{ "externalId": "local-foxray-ai-1", "author": "CONTACT", "text": "Хочу узнать условия для FoxRay." },
{ "externalId": "local-foxray-ai-2", "author": "AI", "text": "Подскажу подходящий вариант и отвечу на вопросы." }
]
},
{
"key": "foxray-queue",
"channel": "foxray-sales",
"connection": "foxray-max",
"contact": "alex",
"externalChatId": "local-foxray-queue",
"controlMode": "PAUSED",
"expectedResponder": "OPERATOR",
"lastActivityMinutesAgo": 12,
"messages": [
{ "externalId": "local-foxray-queue-1", "author": "CONTACT", "text": "Нужна помощь с выбором тарифа." }
]
},
{
"key": "firepage-support",
"channel": "firepage-support",
"connection": "firepage-tg",
"contact": "olga",
"externalChatId": "local-firepage-support",
"controlMode": "HUMAN",
"expectedResponder": "OPERATOR",
"lastActivityMinutesAgo": 8,
"messages": [
{ "externalId": "local-firepage-support-1", "author": "CONTACT", "text": "Не получается войти в кабинет." },
{ "externalId": "local-firepage-support-2", "author": "OPERATOR", "text": "Проверяю доступ к вашему аккаунту." }
]
}
],
"orders": [
{ "externalId": "local-order-paid", "product": "foxray", "contact": "maria", "conversation": "foxray-ai", "paymentStatus": "PAID", "fulfillmentStatus": "DELIVERED", "amountMinor": 299000 },
{ "externalId": "local-order-pending", "product": "foxray", "contact": "alex", "conversation": "foxray-queue", "paymentStatus": "PENDING", "fulfillmentStatus": "PENDING", "amountMinor": 99000 },
{ "externalId": "local-order-failed", "product": "firepage", "contact": "olga", "conversation": "firepage-support", "paymentStatus": "PAID", "fulfillmentStatus": "FAILED", "amountMinor": 149000 }
],
"aiInvocations": [
{ "channel": "foxray-sales", "product": "foxray", "purpose": "local-seed-sales", "model": "gpt-5-mini", "promptTokens": 4200, "completionTokens": 1100, "costMicros": 156000 },
{ "channel": "firepage-support", "product": "firepage", "purpose": "local-seed-support", "model": "gpt-5-mini", "promptTokens": 2800, "completionTokens": 760, "costMicros": 98000 }
]
}
-278
View File
@@ -1,278 +0,0 @@
#!/usr/bin/env python3
"""Explicit, local-only CustoCRM demo data importer.
This file is mounted only by compose.dev.yaml and is intentionally outside the
backend image. It must never be copied into or executed by a production setup.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import timedelta
from pathlib import Path
SEED_DIR = Path(__file__).resolve().parent
MANIFEST_PATH = SEED_DIR / "manifest.json"
def require_local_environment() -> None:
if os.environ.get("CUS_ENV") != "local" or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1":
raise SystemExit("Local seed is available only through compose.dev.yaml with CUS_ENV=local.")
def load_manifest() -> dict:
with MANIFEST_PATH.open(encoding="utf-8") as stream:
manifest = json.load(stream)
if manifest.get("schemaVersion") != 1:
raise SystemExit("Unsupported local seed manifest version.")
return manifest
def run() -> None:
require_local_environment()
sys.path.insert(0, "/app/apps/backend")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
import django
django.setup()
from django.db import transaction
from django.utils import timezone
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import AIAgent, AIAgentStatus, LlmInvocation
from hub_platform.channels.models import Channel
from hub_platform.conversations.models import (
ConnectionIdentity,
Contact,
Conversation,
ControlMode,
ExpectedResponder,
LifecycleState,
Message,
MessageAuthor,
)
from hub_platform.identity.access_defaults import ensure_system_assignment
from hub_platform.identity.models import Department, EmployeeRole, HumanUser, Organization, OrganizationMembership
from hub_platform.integrations.models import Integration, IntegrationKind
from hub_platform.orders.models import FulfillmentStatus, Order, PaymentStatus
from hub_platform.products.models import Product, ProductDepartment
from hub_platform.subscriptions.default_subscription import ensure_default_subscription
manifest = load_manifest()
now = timezone.now()
with transaction.atomic():
org_data = manifest["organization"]
organization, _ = Organization.objects.get_or_create(
slug=org_data["slug"],
defaults={
"name": org_data["name"],
"timezone": org_data["timezone"],
"currency": org_data["currency"],
},
)
ensure_uncategorized_category(organization)
ensure_default_subscription(organization)
departments = {}
for item in manifest["departments"]:
department, _ = Department.objects.get_or_create(
organization=organization, code=item["code"], defaults={"name": item["name"]}
)
departments[item["code"]] = department
accounts = manifest["accounts"]
owner_data = accounts["owner"]
owner, owner_created = HumanUser.objects.get_or_create(
email=HumanUser.objects.normalize_email(owner_data["email"]),
defaults={"full_name": owner_data["fullName"], "is_staff": True, "is_superuser": True},
)
if owner_created:
owner.set_password(owner_data["password"])
owner.save(update_fields=["password"])
owner_membership, _ = OrganizationMembership.objects.get_or_create(
user=owner,
organization=organization,
defaults={"role": EmployeeRole.OWNER, "position_title": owner_data["positionTitle"]},
)
if owner_membership.role != EmployeeRole.OWNER:
raise SystemExit(f"{owner.email} is not the owner of local organization {organization.slug}.")
operator_data = accounts["operator"]
operator, operator_created = HumanUser.objects.get_or_create(
email=HumanUser.objects.normalize_email(operator_data["email"]),
defaults={"full_name": operator_data["fullName"]},
)
if operator_created:
operator.set_password(operator_data["password"])
operator.save(update_fields=["password"])
operator_membership, _ = OrganizationMembership.objects.get_or_create(
user=operator,
organization=organization,
defaults={
"role": EmployeeRole.EMPLOYEE,
"position_title": operator_data["positionTitle"],
"phone": operator_data["phone"],
"primary_department": departments["sales"],
},
)
ensure_system_assignment(
employee=operator_membership,
assigned_by=owner_membership,
department=departments["sales"],
profile_name="Sales operator",
)
products = {}
for item in manifest["products"]:
product, _ = Product.objects.get_or_create(
organization=organization, code=item["code"], defaults={"name": item["name"]}
)
products[item["code"]] = product
for department_code in item["departments"]:
ProductDepartment.objects.get_or_create(product=product, department=departments[department_code])
channels = {}
for item in manifest["channels"]:
channel, _ = Channel.objects.get_or_create(
organization=organization,
code=item["code"],
defaults={
"name": item["name"],
"department": departments.get(item["department"]),
"product": products.get(item["product"]),
"is_active": item.get("isActive", True),
**item["policy"],
},
)
channels[item["code"]] = channel
agent_data = item.get("agent")
if agent_data:
AIAgent.objects.get_or_create(
channel=channel,
defaults={
"name": agent_data["name"],
"model": agent_data["model"],
"status": AIAgentStatus.ACTIVE,
},
)
integrations = {}
for item in manifest["integrations"]:
integration, _ = Integration.objects.get_or_create(
organization=organization,
provider=item["provider"],
name=item["name"],
defaults={
"kind": IntegrationKind.MESSENGER,
"channel": channels[item["channel"]],
"status": item["status"],
},
)
integrations[item["key"]] = integration
contacts = {}
for item in manifest["contacts"]:
connection = integrations[item["connection"]]
identity = ConnectionIdentity.objects.filter(
connection=connection, external_user_id=item["externalUserId"]
).select_related("contact").first()
if identity is None:
contact = Contact.objects.create(organization=organization, name=item["name"], phone=item["phone"])
ConnectionIdentity.objects.create(
organization=organization,
contact=contact,
connection=connection,
external_user_id=item["externalUserId"],
display_name=item["name"],
)
else:
contact = identity.contact
contacts[item["key"]] = contact
conversations = {}
for item in manifest["conversations"]:
conversation, _ = Conversation.objects.get_or_create(
organization=organization,
channel=channels[item["channel"]],
external_chat_id=item["externalChatId"],
defaults={
"connection": integrations[item["connection"]],
"contact": contacts[item["contact"]],
"lifecycle": LifecycleState.OPEN,
"control_mode": item["controlMode"],
"expected_responder": item["expectedResponder"],
"assigned_operator": operator if item["controlMode"] == ControlMode.HUMAN else None,
"transport_meta": {"localSeedKey": item["key"]},
},
)
Conversation.objects.filter(pk=conversation.pk).update(
last_activity_at=now - timedelta(minutes=item["lastActivityMinutesAgo"])
)
conversation.refresh_from_db()
conversations[item["key"]] = conversation
for message in item["messages"]:
Message.objects.get_or_create(
conversation=conversation,
external_id=message["externalId"],
defaults={
"author_type": message["author"],
"author_user": operator if message["author"] == MessageAuthor.OPERATOR else None,
"text": message["text"],
},
)
for item in manifest["orders"]:
payment_status = item["paymentStatus"]
Order.objects.get_or_create(
organization=organization,
source="local-seed",
external_id=item["externalId"],
defaults={
"product": products[item["product"]],
"contact": contacts[item["contact"]],
"conversation": conversations[item["conversation"]],
"channel": conversations[item["conversation"]].channel,
"payment_status": payment_status,
"fulfillment_status": item["fulfillmentStatus"],
"amount_minor": item["amountMinor"],
"paid_at": now if payment_status == PaymentStatus.PAID else None,
},
)
for item in manifest["aiInvocations"]:
channel = channels[item["channel"]]
LlmInvocation.objects.get_or_create(
channel=channel,
purpose=item["purpose"],
operation="chat",
model=item["model"],
defaults={
"product": products[item["product"]],
"prompt_tokens": item["promptTokens"],
"completion_tokens": item["completionTokens"],
"total_tokens": item["promptTokens"] + item["completionTokens"],
"cost_micros": item["costMicros"],
},
)
print(json.dumps({
"organization": organization.slug,
"organizationPublicId": str(organization.public_id),
"owner": owner.email,
"operator": operator.email,
"channels": Channel.objects.filter(organization=organization).count(),
"integrations": Integration.objects.filter(organization=organization).count(),
"conversations": Conversation.objects.filter(organization=organization).count(),
"orders": Order.objects.filter(organization=organization).count(),
"idempotent": True,
}, ensure_ascii=False))
if __name__ == "__main__":
run()
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python3
"""Local-only, credential-safe demo importer for the FoxRay support portal."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
SEED_DIR = Path(__file__).resolve().parent
MANIFEST_PATH = SEED_DIR / "support_portal.json"
def require_local_environment() -> None:
if (
os.environ.get("CUS_ENV") != "local"
or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1"
):
raise SystemExit(
"Support portal seed is available only through compose.dev.yaml "
"with CUS_ENV=local."
)
def load_manifest() -> dict:
with MANIFEST_PATH.open(encoding="utf-8") as stream:
manifest = json.load(stream)
if manifest.get("schemaVersion") != 1:
raise SystemExit("Unsupported support portal seed manifest version.")
return manifest
def update_fields(instance, values: dict) -> None:
changed = []
for field, value in values.items():
if getattr(instance, field) != value:
setattr(instance, field, value)
changed.append(field)
if changed:
instance.full_clean()
if any(field.name == "updated_at" for field in instance._meta.fields):
changed.append("updated_at")
instance.save(update_fields=changed)
def run() -> None:
require_local_environment()
sys.path.insert(0, "/app/apps/backend")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
import django
django.setup()
from django.db import transaction
from django.utils import timezone
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department, Organization
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
IntegrationStatus,
)
from hub_platform.products.models import Product
from hub_platform.support_portals.addressing import hosted_domain
from hub_platform.support_portals.models import (
PortalArticle,
PortalArticleRevision,
PortalCategory,
SupportPortal,
SupportPortalProduct,
)
from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
manifest = load_manifest()
portal_data = manifest["portal"]
widget_data = manifest["widget"]
now = timezone.now()
with transaction.atomic():
organization = Organization.objects.get(slug=manifest["organization"])
department = Department.objects.get(
organization=organization,
code="support",
)
product = Product.objects.get(
organization=organization,
code=portal_data["product"],
)
channel, _ = Channel.objects.get_or_create(
organization=organization,
code=widget_data["channel"],
defaults={
"name": widget_data["channelName"],
"department": department,
"product": product,
},
)
update_fields(
channel,
{
"name": widget_data["channelName"],
"department": department,
"product": product,
"is_active": True,
"requires_authenticated_product_identity": False,
"allow_anonymous_sessions": True,
"allow_self_reported_contact": True,
"allow_sales_attribution": False,
"allow_checkout_actions": False,
},
)
integration, _ = Integration.objects.get_or_create(
organization=organization,
provider=IntegrationProvider.WEB,
name=widget_data["integrationName"],
defaults={"kind": IntegrationKind.MESSENGER},
)
update_fields(
integration,
{
"kind": IntegrationKind.MESSENGER,
"channel": channel,
"config": widget_data["config"],
"status": IntegrationStatus.OK,
"is_active": True,
"last_error": "",
},
)
portal, _ = SupportPortal.objects.get_or_create(
slug=portal_data["slug"],
defaults={
"organization": organization,
"department": department,
"hosted_domain": hosted_domain(portal_data["slug"]),
"name": portal_data["name"],
"default_locale": portal_data["locale"],
},
)
update_fields(
portal,
{
"department": department,
"hosted_domain": hosted_domain(portal_data["slug"]),
"name": portal_data["name"],
"default_locale": portal_data["locale"],
"widget_channel": channel,
"status": PortalStatus.PUBLISHED,
"published_at": portal.published_at or now,
},
)
authenticated_channel = Channel.objects.filter(
organization=organization,
code=portal_data["authenticatedSupportChannel"],
product=product,
department=department,
requires_authenticated_product_identity=True,
allow_anonymous_sessions=False,
).first()
product_link, _ = SupportPortalProduct.objects.get_or_create(
organization=organization,
portal=portal,
product=product,
defaults={"support_channel": authenticated_channel},
)
if product_link.support_channel_id != getattr(
authenticated_channel,
"id",
None,
):
product_link.support_channel = authenticated_channel
product_link.full_clean()
product_link.save(update_fields=["support_channel"])
categories = {}
for item in manifest["categories"]:
category, _ = PortalCategory.objects.get_or_create(
organization=organization,
portal=portal,
slug=item["slug"],
defaults={"name": item["name"]},
)
update_fields(
category,
{
"name": item["name"],
"description": item["description"],
"sort_order": item["sortOrder"],
},
)
categories[item["slug"]] = category
for item in manifest["articles"]:
article, _ = PortalArticle.objects.get_or_create(
organization=organization,
portal=portal,
locale=portal_data["locale"],
slug=item["slug"],
defaults={"category": categories[item["category"]]},
)
category = categories[item["category"]]
latest = article.revisions.order_by("-revision").first()
revision_content = (
item["title"],
item["summary"],
item["content"],
)
if latest is None or (
latest.title,
latest.summary,
latest.content,
) != revision_content:
latest = PortalArticleRevision.objects.create(
organization=organization,
article=article,
revision=(latest.revision + 1) if latest else 1,
title=item["title"],
summary=item["summary"],
content=item["content"],
published_at=now,
)
elif latest.published_at is None:
latest.published_at = now
latest.save(update_fields=["published_at"])
article.category = category
article.status = ArticleStatus.PUBLISHED
article.published_revision = latest
article.full_clean()
article.save(
update_fields=[
"category",
"status",
"published_revision",
"updated_at",
]
)
print(
json.dumps(
{
"organization": organization.slug,
"portal": portal.slug,
"url": f"http://{portal.hosted_domain}/",
"widgetChannel": channel.code,
"categories": len(categories),
"articles": len(manifest["articles"]),
"idempotent": True,
"credentialsChanged": False,
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
run()
-102
View File
@@ -1,102 +0,0 @@
{
"schemaVersion": 1,
"organization": "edevs",
"portal": {
"slug": "foxray",
"name": "FoxRay",
"locale": "ru",
"product": "foxray",
"authenticatedSupportChannel": "foxray-support"
},
"widget": {
"channel": "foxray-help",
"channelName": "FoxRay — чат поддержки",
"integrationName": "FoxRay Help Center Web",
"config": {
"title": "Поддержка FoxRay",
"accent": "#1677ff",
"greeting": "Здравствуйте! Поможем разобраться с FoxRay.",
"consent_text": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.",
"consent_version": "v1",
"quick_replies": [
"Как начать работу",
"Не могу войти",
"Связаться со специалистом"
],
"allowed_domains": [
"foxray.localhost",
"localhost"
]
}
},
"categories": [
{
"slug": "getting-started",
"name": "Начало работы",
"description": "Первые шаги и основные возможности",
"sortOrder": 10
},
{
"slug": "account-access",
"name": "Аккаунт и доступ",
"description": "Вход, восстановление доступа и уведомления",
"sortOrder": 20
},
{
"slug": "support",
"name": "Обращения в поддержку",
"description": "Создание и отслеживание обращений",
"sortOrder": 30
},
{
"slug": "billing",
"name": "Оплата и документы",
"description": "Счета и закрывающие документы",
"sortOrder": 40
}
],
"articles": [
{
"slug": "first-login",
"category": "getting-started",
"title": "Первый вход в FoxRay",
"summary": "Краткий маршрут после получения доступа.",
"content": "# Первый вход в FoxRay\n\n1. Откройте адрес рабочего пространства, который прислал администратор.\n2. Введите рабочий email и пароль.\n3. Проверьте название организации в верхней части страницы.\n\n> Если приглашение или адрес не открывается, напишите в чат поддержки на этой странице.\n\n## Что проверить после входа\n\n- доступные разделы;\n- профиль и контактные данные;\n- настройки уведомлений."
},
{
"slug": "restore-access",
"category": "account-access",
"title": "Как восстановить доступ",
"summary": "Что проверить, если не получается войти.",
"content": "# Как восстановить доступ\n\nСначала убедитесь, что используете рабочий email и правильный адрес организации.\n\n## Если войти не удалось\n\n- повторите ввод без пробелов в начале и конце;\n- проверьте раскладку и `Caps Lock`;\n- обратитесь к администратору вашей организации;\n- напишите в чат поддержки и укажите рабочий email.\n\nПароль в сообщении отправлять не нужно."
},
{
"slug": "notifications",
"category": "account-access",
"title": "Настройка уведомлений",
"summary": "Как выбрать важные события и не пропустить ответ.",
"content": "# Настройка уведомлений\n\nОткройте настройки профиля и проверьте доступные способы уведомлений.\n\n| Событие | Рекомендация |\n| --- | --- |\n| Ответ специалиста | Оставить включённым |\n| Изменение статуса | Оставить включённым |\n| Информационные сообщения | Настроить по необходимости |\n\nИзменения применяются после сохранения формы."
},
{
"slug": "create-support-request",
"category": "support",
"title": "Как обратиться в поддержку",
"summary": "Какие данные помогут быстрее разобраться в вопросе.",
"content": "# Как обратиться в поддержку\n\nНажмите синюю кнопку чата в правом нижнем углу портала.\n\nОпишите:\n\n- что вы хотели сделать;\n- что произошло вместо ожидаемого результата;\n- адрес страницы;\n- время возникновения проблемы.\n\nСекреты, пароли и платёжные данные отправлять не нужно."
},
{
"slug": "track-request",
"category": "support",
"title": "Где посмотреть статус обращения",
"summary": "Как продолжить уже начатый разговор.",
"content": "# Где посмотреть статус обращения\n\nВернитесь на портал в том же браузере и снова откройте чат. Активная локальная сессия продолжится автоматически.\n\nСтатусы в чате показывают, кто сейчас готовит ответ: виртуальный помощник или специалист поддержки."
},
{
"slug": "payment-documents",
"category": "billing",
"title": "Оплата и документы",
"summary": "Куда обратиться по вопросам счетов и документов.",
"content": "# Оплата и документы\n\nПо вопросу оплаты или закрывающих документов напишите в чат поддержки.\n\nУкажите:\n\n1. название организации;\n2. номер счёта, если он известен;\n3. период, за который нужны документы.\n\nНе прикладывайте данные банковской карты."
}
]
}
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference = "Stop"
# Одноразовый импорт демо-данных организации «Северная Верфь» (выдуманные данные,
# РФ-наполнение). Профиль demo-seed определён только в compose.dev.yaml и не
# запускается обычным `docker compose up`. Сама команда `seed_demo` идемпотентна.
# Запускайте только после того, как локальный dev-стек поднят.
docker compose -f compose.yaml -f compose.dev.yaml --profile demo-seed --env-file .env.example --env-file .env `
run --rm demo-seed
-6
View File
@@ -1,6 +0,0 @@
$ErrorActionPreference = "Stop"
# This script always includes compose.dev.yaml. The importer has no production
# service and refuses to run unless CUS_ENV=local is set in the container.
docker compose -f compose.yaml -f compose.dev.yaml --profile local-seed --env-file .env.example --env-file .env `
run --rm local-seed
-5
View File
@@ -1,5 +0,0 @@
$ErrorActionPreference = "Stop"
# Portal-only demo data: no users, passwords, roles, sessions, or credentials.
docker compose -f compose.yaml -f compose.dev.yaml --profile support-portal-seed `
--env-file .env.example --env-file .env run --rm support-portal-seed