From 0ca9eb865f6989eb03e36b1aa0eb606859d1d1cb Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 5 Sep 2026 16:55:31 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(setup)!:=20=D0=BC=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE-=D0=BD=D0=B0=D0=B1=D0=BE=D1=80=20=C2=AB?= =?UTF-8?q?=D0=90=D1=82=D0=B5=D0=BB=D1=8C=D0=B5=20=D0=9D=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=C2=BB=20=D1=81=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=BC=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open source: «развернуть за минуту». Никаких параметров в .env и CLI — при пустом инстансе браузер показывает мастер (/api/v1/setup/): название организации, имя, e-mail, пароль владельца и флаг «Установить демо-данные». Запись идёт на соединении platform (роль app не создаёт организации) через tenancy/routing.use_database; после первого владельца мастер закрыт (409). bootstrap_owner и захардкоженные Edevs/Котова из установки удалены (bootstrap_edevs_owner остаётся тестовым helper'ом). Демо ставится в организацию установщика; реестр DemoRecord (post_save во время сида) даёт точное удаление в обратном порядке с разрывом PROTECT-циклов только между демо-объектами. Установка/удаление — outbox → worker; статус и витринные учётки (админ + сотрудники разных групп, пароль Chatbolls-Demo-2026) — карточка «Демо-данные» в «Настройках» (/company/demo/). Сид переписан под дизайн-базлайн v2 («Ателье Норд»): 6 сотрудников (группы, блокировка, TOTP, приглашения), 4 агента (активные/черновик/выключенный), подключения TG/MAX/почта/два веб-виджета (анонимный и авторизованный) с ошибкой у одного, знания с иерархией категорий и вложениями (md/txt/pdf), 30 дней истории LLM, 13 диалогов во всех состояниях с метками, приоритетами, заметками, ответственными, историей контакта, спамом и архивом, веб-гость через настоящую сессию виджета, шаблоны «/», портал поддержки со статьями (опубликованные/черновик/архив, вторая ревизия, оценки), звонки с метриками и приглашением, уведомления всех типов, привязки уведомителя. Аватары контактов — портреты владельца, публичный /api/v1/demo-media/. Голосовые — слоты под клипы владельца (media/voice/README.md). Тест покрытия: каждая модель hub_platform получает демо-запись; удаление возвращает счётчики к исходным. compose-профиль demo-seed и seed-demo.ps1 удалены; seed_demo --organization --apply/--remove для разработки; start.sh для Linux/macOS; README — раздел «Быстрый старт». Co-Authored-By: Claude Fable 5.1 --- README.md | 132 ++- apps/backend/hub_backend/settings_base.py | 2 + apps/backend/hub_backend/urls_app.py | 7 + .../hub_platform/identity/company_urls.py | 3 +- .../hub_platform/identity/demo_models.py | 67 ++ .../identity/demo_seed/data/catalog.json | 12 +- .../identity/demo_seed/data/channels_ai.json | 597 +++++----- .../demo_seed/data/conversations.json | 1008 ++++++++++++++--- .../data/media/avatars/darya-volkova.jpg | Bin 0 -> 37821 bytes .../data/media/avatars/irina-levina.jpg | Bin 0 -> 33379 bytes .../data/media/avatars/maria-sokolova.jpg | Bin 0 -> 28494 bytes .../data/media/avatars/olga-melnik.jpg | Bin 0 -> 38861 bytes .../data/media/avatars/sergey-krylov.jpg | Bin 0 -> 24615 bytes .../data/media/dostavka-i-oplata.txt | 13 + .../data/media/katalog-tkanej-2026.pdf | 32 + .../data/media/katalog-tkanej-2026.txt | 14 + .../demo_seed/data/media/raschet-stoimosti.md | 32 + .../data/media/reper-telematics-spec.md | 30 - .../data/media/vektor-deploy-guide.md | 26 - .../demo_seed/data/media/voice/README.md | 15 + .../identity/demo_seed/data/operations.json | 70 +- .../identity/demo_seed/data/organization.json | 125 +- .../identity/demo_seed/data/support.json | 117 +- .../identity/demo_seed/loaders/channels_ai.py | 337 ++++-- .../identity/demo_seed/loaders/common.py | 67 ++ .../demo_seed/loaders/conversations.py | 220 +++- .../identity/demo_seed/loaders/foundation.py | 170 ++- .../identity/demo_seed/loaders/operations.py | 227 ++-- .../identity/demo_seed/loaders/support.py | 174 +-- .../identity/demo_seed/manifest.py | 19 +- .../identity/demo_seed/orchestrator.py | 16 +- .../hub_platform/identity/demo_seed/refs.py | 9 + .../identity/demo_seed/registry.py | 164 +++ .../identity/demo_seed/service.py | 204 ++++ .../hub_platform/identity/demo_views.py | 75 ++ .../hub_platform/identity/event_handlers.py | 40 + .../management/commands/bootstrap_owner.py | 27 - .../identity/management/commands/seed_demo.py | 111 +- .../migrations/0022_demo_dataset_registry.py | 48 + apps/backend/hub_platform/identity/models.py | 4 + apps/backend/hub_platform/identity/setup.py | 166 +++ .../hub_platform/identity/setup_urls.py | 8 + .../hub_platform/identity/setup_views.py | 75 ++ .../hub_platform/identity/test_seed_demo.py | 286 +++-- .../hub_platform/identity/test_setup.py | 103 ++ .../migrations/0022_demo_registry_guards.py | 49 + apps/backend/hub_platform/tenancy/routing.py | 45 + apps/internal-ui/src/App.tsx | 11 +- .../src/features/auth/AuthField.tsx | 2 +- .../src/features/auth/AuthScreens.tsx | 1 + .../src/features/auth/AuthSetup.tsx | 92 ++ apps/internal-ui/src/features/auth/styles.css | 38 + .../src/features/settings/DemoDataCard.tsx | 167 +++ .../src/features/settings/SettingsPage.tsx | 4 + .../src/features/settings/styles.css | 80 ++ compose.dev.yaml | 26 - compose.yaml | 4 +- scripts/seed-demo.ps1 | 8 - scripts/start.sh | 20 + 59 files changed, 4001 insertions(+), 1398 deletions(-) create mode 100644 apps/backend/hub_platform/identity/demo_models.py create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/avatars/darya-volkova.jpg create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/avatars/sergey-krylov.jpg create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/dostavka-i-oplata.txt create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.pdf create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md delete mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md delete mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md create mode 100644 apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md create mode 100644 apps/backend/hub_platform/identity/demo_seed/loaders/common.py create mode 100644 apps/backend/hub_platform/identity/demo_seed/registry.py create mode 100644 apps/backend/hub_platform/identity/demo_seed/service.py create mode 100644 apps/backend/hub_platform/identity/demo_views.py delete mode 100644 apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py create mode 100644 apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py create mode 100644 apps/backend/hub_platform/identity/setup.py create mode 100644 apps/backend/hub_platform/identity/setup_urls.py create mode 100644 apps/backend/hub_platform/identity/setup_views.py create mode 100644 apps/backend/hub_platform/identity/test_setup.py create mode 100644 apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py create mode 100644 apps/backend/hub_platform/tenancy/routing.py create mode 100644 apps/internal-ui/src/features/auth/AuthSetup.tsx create mode 100644 apps/internal-ui/src/features/settings/DemoDataCard.tsx delete mode 100644 scripts/seed-demo.ps1 create mode 100644 scripts/start.sh diff --git a/README.md b/README.md index dbb8e5c..524b369 100644 --- a/README.md +++ b/README.md @@ -2,88 +2,96 @@ Canonical implementation workspace for `hub.edevs.tech`. -## Local start +## Быстрый старт (одна минута) + +Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose +на Linux). Никаких параметров заранее задавать не нужно — всё спросит браузер. + +Windows (PowerShell): ```powershell -Copy-Item .env.example .env +git clone chatbolls +cd chatbolls/code/custocrm .\scripts\start.ps1 ``` -По умолчанию локально запускается облачный режим. Коробочный режим запускается -тем же штатным контуром, но с явным признаком поставки: +Linux / macOS: + +```bash +git clone chatbolls +cd chatbolls/code/custocrm +./scripts/start.sh +``` + +Скрипт копирует `.env.example` в `.env` (если его нет), собирает образы и +поднимает стек. Когда в логах появится готовность, откройте +**http://localhost** — вместо входа система покажет **мастер первого запуска**: +название организации, ваше имя, e-mail и пароль владельца, переключатель +«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под +владельцем. Мастер доступен только пока в системе нет ни одной организации; +после создания владельца он закрывается навсегда. + +### Демо-данные + +Демо — вымышленное ателье «Норд» (дизайн-базлайн v2): сотрудники и группы, +агенты с подключениями Telegram/MAX/почта/веб-виджет, база знаний с +вложениями, диалоги во всех состояниях (AI ведёт, ждёт оператора, ведёт +сотрудник, закрыт, спам, архив), метки, приоритеты, заметки, шаблоны +ответов, голосовые, портал поддержки со статьями, звонки, уведомления и +история использования AI за 30 дней. Набор покрывает каждую модель системы — +это проверяет тест `identity.test_seed_demo`. + +Демо ставится в вашу организацию и удаляется целиком одной кнопкой: **Настройки → +Демо-данные**. Там же — учётные записи демо-сотрудников из разных групп, чтобы +посмотреть систему их глазами (пароль общий и намеренно публичный — +`Chatbolls-Demo-2026`). Ваши данные при удалении не затрагиваются: сид ведёт +реестр созданных записей и удаляет ровно их. + +Для разработки то же доступно из командной строки: + +```bash +docker compose run --rm backend-app python manage.py seed_demo --organization --apply +docker compose run --rm backend-app python manage.py seed_demo --organization --remove +``` + +Редактируемые данные — `apps/backend/hub_platform/identity/demo_seed/data/` +(JSON-манифест на домен, `media/` — вложения, аватары, голосовые). Голосовые +сообщения читаются из `media/voice/` (см. README там); если файла нет, сообщение +пропускается. + +### Режим поставки + +По умолчанию локально запускается облачный режим. Коробочный режим — тем же +контуром с явным признаком поставки: ```powershell .\scripts\start.ps1 -Mode Cloud .\scripts\start.ps1 -Mode SelfHosted ``` -Приложение не определяет режим по домену, числу организаций или данным тарифа. +Приложение не определяет режим по домену, числу организаций или данным. Единственный источник — `CUS_DELIVERY_MODE` со значением `CLOUD` или `SELF_HOSTED`. В production переменная обязательна; шаблон коробочного экземпляра `env.example` уже содержит `SELF_HOSTED`. -The local compose stack contains: +### Что поднимается -- isolated app, platform, and loopback-only admin Django runtimes; -- background worker; -- PostgreSQL; -- Redis; -- Internal Hub UI; -- Web Chat UI; -- local Nginx reverse proxy. +- изолированные Django-рантаймы app, platform и loopback-only admin; +- фоновый worker (outbox, поллинг мессенджеров, установка демо); +- PostgreSQL и Redis; +- Internal Hub UI и Web Chat UI; +- локальный Nginx reverse proxy. -No production secrets are stored in the repository. +Секретов production в репозитории нет. -Default local URLs: +Локальные адреса: -- App gateway: `http://app.localhost/` -- Platform health: `http://platform.localhost/api/v1/health/live/` -- Django admin (loopback only): `http://127.0.0.1:18001/admin/` -- Internal Hub UI (direct Vite): `http://localhost:5173` +- Приложение: `http://localhost` (то же — `http://app.localhost/`) +- Health платформы: `http://platform.localhost/api/v1/health/live/` +- Django admin (только loopback): `http://127.0.0.1:18001/admin/` +- Internal Hub UI напрямую (Vite): `http://localhost:5173` - Web Chat: `http://localhost:5175` -- App API (direct): `http://localhost:8010/api/v1` - -Local accounts (TOTP disabled). These credentials are fixed — do not change them: - -- OWNER — `owner@edevs.tech` / `Owner-Local-2026` -- OPERATOR — `a.kotova@edevs.tech` / `Operator-Local-2026` - -Bootstrap the organization and both accounts: - -```powershell -docker compose run --rm backend-app python manage.py bootstrap_owner --email owner@edevs.tech --password Owner-Local-2026 --name "Иван Петров" -``` - -## Optional demo seed - -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-demo.ps1 -``` - -Or directly via management command (dry-run by default, `--apply` to write): - -``` -python manage.py seed_demo --apply -``` - -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. +- App API напрямую: `http://localhost:8010/api/v1` ## Tests diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index e432824..f6067c7 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -121,6 +121,8 @@ CHANNEL_LAYERS = { } } +DATABASE_ROUTERS = ["hub_platform.tenancy.routing.ForcedAliasRouter"] + AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, { diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 3d3459d..862fd43 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -1,6 +1,7 @@ from django.urls import include, path from rest_framework.schemas import get_schema_view +from hub_platform.identity.demo_views import DemoMediaView from hub_platform.webchat.views import WidgetLoaderView urlpatterns = [ @@ -11,6 +12,12 @@ urlpatterns = [ name="openapi-schema", ), path("api/v1/auth/", include("hub_platform.identity.auth_urls")), + path("api/v1/setup/", include("hub_platform.identity.setup_urls")), + path( + "api/v1/demo-media/avatars/", + DemoMediaView.as_view(), + name="demo-media", + ), path( "api/v1/organizations//company/", include("hub_platform.identity.company_urls"), diff --git a/apps/backend/hub_platform/identity/company_urls.py b/apps/backend/hub_platform/identity/company_urls.py index 8b5fd86..4c50df4 100644 --- a/apps/backend/hub_platform/identity/company_urls.py +++ b/apps/backend/hub_platform/identity/company_urls.py @@ -1,6 +1,6 @@ from django.urls import path -from hub_platform.identity import administration_views, group_views +from hub_platform.identity import administration_views, demo_views, group_views urlpatterns = [ path("groups/", group_views.GroupListView.as_view(), name="group-list"), @@ -20,6 +20,7 @@ urlpatterns = [ administration_views.LaunchChecklistView.as_view(), name="launch-checklist", ), + path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"), path( "administration/audit/", administration_views.AuditListView.as_view(), diff --git a/apps/backend/hub_platform/identity/demo_models.py b/apps/backend/hub_platform/identity/demo_models.py new file mode 100644 index 0000000..3aec30a --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_models.py @@ -0,0 +1,67 @@ +"""Реестр демо-данных организации. + +Демо ставится в организацию установщика (мультиорг-переключателя нет — +решение владельца). Чтобы удаление было точным и не задело реальные данные, +каждая созданная сидом запись регистрируется в ``DemoRecord``; удаление идёт +по реестру в обратном порядке создания. +""" + +from __future__ import annotations + +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class DemoDatasetStatus(models.TextChoices): + INSTALLING = "INSTALLING", "Устанавливается" + INSTALLED = "INSTALLED", "Установлены" + REMOVING = "REMOVING", "Удаляются" + FAILED = "FAILED", "Ошибка" + + +class DemoDataset(models.Model): + """Состояние демо-набора организации: одна запись на организацию.""" + + organization = models.OneToOneField( + "identity.Organization", on_delete=models.CASCADE, related_name="demo_dataset" + ) + status = models.CharField( + max_length=16, choices=DemoDatasetStatus.choices, default=DemoDatasetStatus.INSTALLING + ) + requested_by = models.ForeignKey( + "identity.HumanUser", null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + records_count = models.PositiveIntegerField(default=0) + error = models.TextField(blank=True, default="") + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "identity_demodataset" + + def __str__(self) -> str: + return f"demo:{self.organization_id}:{self.status}" + + +class DemoRecord(models.Model): + """Одна созданная сидом запись: модель + первичный ключ + порядок создания.""" + + organization = models.ForeignKey( + "identity.Organization", on_delete=models.CASCADE, related_name="+" + ) + dataset = models.ForeignKey(DemoDataset, on_delete=models.CASCADE, related_name="records") + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="+") + object_id = models.CharField(max_length=64) + sequence = models.PositiveBigIntegerField() + + class Meta: + db_table = "identity_demorecord" + indexes = [models.Index(fields=["dataset", "sequence"])] + constraints = [ + models.UniqueConstraint( + fields=["dataset", "content_type", "object_id"], name="identity_demorecord_unique" + ) + ] + + def __str__(self) -> str: + return f"{self.content_type_id}:{self.object_id}" diff --git a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json b/apps/backend/hub_platform/identity/demo_seed/data/catalog.json index 3e77b35..a4ee2ff 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/catalog.json @@ -1,15 +1,7 @@ { "schemaVersion": 1, + "_comment": "Product — скрытая техническая ось (ADR-HUB-0041): нужен порталу поддержки и веб-виджету.", "products": [ - { - "code": "vektor", - "name": "Вектор", - "siteUrl": "https://vektor.severnayaverf.ru" - }, - { - "code": "reper", - "name": "Репер", - "siteUrl": "https://reper.severnayaverf.ru" - } + {"code": "atelie-nord-site", "name": "Сайт ателье", "siteUrl": "https://atelie-nord.ru"} ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json b/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json index 99aad17..524325b 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json @@ -1,305 +1,340 @@ { "schemaVersion": 1, - "integrations": [ - { - "key": "llm-provider", - "provider": "OPENROUTER", - "name": "OpenRouter (CustoAI)", - "kind": "LLM_PROVIDER", - "channel": null, - "status": "OK" + "_comment": "Агенты = карточки поверх Channel+AIAgent (ADR-HUB-0041 §4). Подключения — точки входа диалогов; LLM-провайдер общий на организацию (BYOK). Веб-виджетов два: анонимный на сайте (агент «Консультант») и авторизованный в личном кабинете (агент «Поддержка сайта», политика обязательной идентичности — SPEC-HUB-0027 P4/P5).", + "llmProvider": { + "key": "llm", + "provider": "OPENROUTER", + "name": "OpenRouter (демо-ключ)", + "secret": "sk-or-demo-not-a-real-key", + "config": { + "baseUrl": "https://openrouter.ai/api/v1", + "defaultModel": "openai/gpt-4o-mini" }, - { - "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": "Северная Верфь — главный сайт", - "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 - }, - "group": "sales" - }, - { - "code": "vektor-sales", - "name": "Вектор — продажи", - "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 - }, - "group": "sales" - }, - { - "code": "reper-sales", - "name": "Репер — продажи", - "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 - }, - "group": "sales" - }, - { - "code": "vektor-support", - "name": "Вектор — поддержка", - "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 - }, - "group": "support" - }, - { - "code": "reper-support", - "name": "Репер — поддержка", - "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 - }, - "group": "support" - }, - { - "code": "partners", - "name": "Партнёрская линия", - "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 - }, - "group": null - }, - { - "code": "reper-help-widget", - "name": "Репер — виджет Help-центра", - "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 - }, - "group": "support" - } - ], + "status": "UNCHECKED" + }, "agents": [ { - "channel": "site-main", - "name": "Главный ассистент сайта", - "model": "anthropic/claude-sonnet-4.6", - "status": "ACTIVE", - "persona": "Виртуальный ассистент компании «Северная Верфь» — производителя промышленной телеметрии. Помогает посетителям сайта сориентироваться в продуктах Вектор и Репер.", - "tone": "Дружелюбный, деловой, без излишней фамильярности. Обращается на «вы».", - "instructions": "Если вопрос касается конкретного продукта или условий покупки — переключай диалог на менеджера продаж. Не обещай скидки и сроки, которых нет в базе знаний.", + "key": "consultant", + "code": "consultant", + "name": "Консультант", + "group": "consultants", + "aiStatus": "ACTIVE", + "persona": "Ты — консультант ателье «Норд». Ателье шьёт шторы, тюль и текстиль для дома по индивидуальным размерам, доставляет по России.", + "tone": "Дружелюбно, коротко, без канцелярита. Обращайся по имени, если клиент представился.", + "instructions": "Отвечай только по знаниям ателье. Сроки, цены и статус заказа называй только из базы знаний; если ответа нет — скажи, что уточнишь у коллег, и передай диалог сотруднику. Не обещай скидок. Если клиент просит человека — сразу передавай диалог.", "knowledge": [ - "vektor-overview", - "reper-overview" + "pricing", + "fabrics", + "measure", + "delivery", + "care" + ], + "portalArticles": [ + "kak-snyat-merki", + "sroki-dostavki" + ], + "limits": { + "dailyCostUsd": 300 + }, + "createdDaysAgo": 41 + }, + { + "key": "support", + "code": "site-support", + "name": "Поддержка сайта", + "group": "support", + "product": "atelie-nord-site", + "aiStatus": "ACTIVE", + "persona": "Ты — помощник службы поддержки сайта ателье «Норд»: личный кабинет, заказы, оплата, доставка, возвраты.", + "tone": "Спокойно и по делу.", + "instructions": "Сначала проверь знания о личном кабинете и возвратах. Вопросы по конкретному заказу без номера заказа — попроси номер. Жалобы на качество — передай сотруднику с пометкой.", + "knowledge": [ + "delivery", + "returns", + "account" + ], + "portalArticles": [ + "vozvrat-i-obmen", + "lichnyj-kabinet" + ], + "limits": {}, + "createdDaysAgo": 30, + "policy": { + "requires_authenticated_product_identity": true, + "allow_anonymous_sessions": false, + "allow_self_reported_contact": false + } + }, + { + "key": "reception", + "code": "reception", + "name": "Приёмная", + "group": null, + "aiStatus": "DRAFT", + "persona": "", + "tone": "", + "instructions": "", + "knowledge": [], + "portalArticles": [], + "limits": {}, + "createdDaysAgo": 3 + }, + { + "key": "legacy", + "code": "old-line", + "name": "Старая линия (Instagram)", + "group": "operators", + "isActive": false, + "aiStatus": "DISABLED", + "persona": "Консультант ателье.", + "tone": "", + "instructions": "Канал закрыт весной 2026.", + "knowledge": [ + "pricing" + ], + "portalArticles": [], + "limits": {}, + "createdDaysAgo": 180 + } + ], + "connections": [ + { + "key": "tg", + "provider": "TELEGRAM", + "name": "Telegram · @AtelieNordBot", + "agent": "consultant", + "status": "OK", + "secret": "0000000000:DEMO-token-not-real", + "config": { + "username": "AtelieNordBot" + }, + "createdDaysAgo": 40 + }, + { + "key": "max", + "provider": "MAX", + "name": "MAX · Ателье Норд", + "agent": "consultant", + "status": "OK", + "secret": "demo-max-token", + "config": {}, + "createdDaysAgo": 25 + }, + { + "key": "email", + "provider": "EMAIL", + "name": "Почта · info@atelie-nord.ru", + "agent": "support", + "status": "OK", + "secret": "demo-imap-password", + "config": { + "email": "info@atelie-nord.ru", + "imapHost": "imap.atelie-nord.ru", + "smtpHost": "smtp.atelie-nord.ru" + }, + "createdDaysAgo": 29 + }, + { + "key": "web", + "provider": "WEB", + "name": "Виджет сайта atelie-nord.ru", + "agent": "consultant", + "status": "OK", + "config": { + "title": "Ателье Норд", + "greeting": "Здравствуйте! Подскажем по тканям, срокам и заказам.", + "quick_replies": [ + "Сроки пошива", + "Статус заказа", + "Возврат" + ], + "allowed_origins": [ + "https://atelie-nord.ru" + ] + }, + "createdDaysAgo": 28 + }, + { + "key": "web_account", + "provider": "WEB", + "name": "Виджет личного кабинета", + "agent": "support", + "status": "OK", + "config": { + "title": "Поддержка · Ателье Норд", + "greeting": "Здравствуйте! Мы видим ваш заказ — чем помочь?", + "quick_replies": [ + "Статус заказа", + "Изменить адрес", + "Возврат" + ], + "allowed_origins": [ + "https://atelie-nord.ru", + "https://lk.atelie-nord.ru" + ] + }, + "createdDaysAgo": 27 + }, + { + "key": "tg_delivery", + "provider": "TELEGRAM", + "name": "Telegram · бот доставки", + "agent": "reception", + "status": "ERROR", + "lastError": "401 Unauthorized: bot token was revoked in @BotFather", + "secret": "1111111111:DEMO-revoked", + "config": { + "username": "AtelieNordDeliveryBot" + }, + "createdDaysAgo": 3 + } + ], + "knowledgeCategories": [ + { + "key": "sewing", + "name": "Пошив и ткани", + "children": [ + { + "key": "measure", + "name": "Замер и расчёт" + }, + { + "key": "fabrics", + "name": "Ткани" + } ] }, { - "channel": "vektor-sales", - "name": "Менеджер продаж Вектора", - "model": "anthropic/claude-sonnet-4.6", - "status": "ACTIVE", - "persona": "Отвечает за продажи коробочной лицензии Вектор производственным предприятиям.", - "tone": "Деловой, экспертный, по сути.", - "instructions": "Уточняй масштаб производства и число участков — от этого зависит конфигурация. Предлагай продление поддержки как сопутствующую услугу.", - "knowledge": [ - "vektor-overview", - "vektor-deploy" + "key": "orders", + "name": "Заказ и доставка", + "children": [ + { + "key": "delivery", + "name": "Доставка" + }, + { + "key": "returns", + "name": "Возврат и обмен" + } ] }, { - "channel": "reper-sales", - "name": "Менеджер продаж Репера", - "model": "anthropic/claude-sonnet-4.6", - "status": "ACTIVE", - "persona": "Отвечает за продажи облачной подписки на телематику Репер.", - "tone": "Деловой, ориентируется на выгоду клиента.", - "instructions": "Уточняй размер парка техники и типы транспорта. Для парков от 50 единиц предлагай годовую оплату — она выгоднее помесячной.", - "knowledge": [ - "reper-overview", - "reper-telematics" - ] + "key": "site", + "name": "Сайт и личный кабинет" } ], "knowledge": [ { - "key": "vektor-overview", - "title": "Вектор — обзор продукта", - "description": "Краткое описание SCADA-системы Вектор для предпродажных консультаций.", - "content": "Вектор — сервер SCADA-мониторинга промышленного оборудования. Разворачивается на площадке заказчика, поставляется коробочной лицензией на один производственный участок. Поддерживает опрос контроллеров по OPC UA и Modbus, сбор метрик в реальном времени, алёртинг при выходе параметров за допуск.", - "visibility": "ORGANIZATION" - }, - { - "key": "vektor-deploy", - "title": "Вектор — руководство по развёртыванию", - "description": "Требования к серверу и шаги развёртывания коробочной лицензии. Приложен файл с детальным руководством.", - "content": "Подробное руководство по развёртыванию коробочной лицензии Вектор приведено во вложении. Требуется сервер с ОС Linux, 8 ГБ ОЗУ, доступ к контроллерам по сети.", - "visibility": "DEPARTMENTS", + "key": "pricing", + "category": "measure", + "title": "Расчёт стоимости пошива штор", + "description": "Формула расчёта, коэффициенты сборки, минимальный заказ", + "content": "Стоимость = ткань (метраж × цена за метр) + пошив (по ширине изделия) + фурнитура.\n\nКоэффициент сборки: 1.5 для плотных портьер, 2.0–2.5 для тюля. Ширина ткани с учётом сборки = ширина карниза × коэффициент.\n\nПошив: 450 ₽ за погонный метр готовой ширины для портьер, 350 ₽ — для тюля. Подгиб по низу входит. Люверсы — 90 ₽ за штуку, шторная лента — 120 ₽ за метр.\n\nМинимальный заказ — 3 000 ₽. Расчёт консультант делает по ширине и высоте окна; замер выезд по Санкт-Петербургу бесплатный при заказе от 15 000 ₽.", "attachments": [ - "vektor-deploy-guide.md" - ] + "raschet-stoimosti.md" + ], + "createdDaysAgo": 40 }, { - "key": "reper-overview", - "title": "Репер — обзор продукта", - "description": "Краткое описание телематики транспорта для предпродажных консультаций.", - "content": "Репер — облачная платформа телематики для транспорта и спецтехники. Сбор данных с бортовых контроллеров, контроль топлива, моточасов, геозон. Оплата по подписке за единицу техники.", - "visibility": "ORGANIZATION" - }, - { - "key": "reper-telematics", - "title": "Репер — спецификация телематики", - "description": "Поддерживаемые протоколы и бортовые контроллеры. Приложен файл со спецификацией.", - "content": "Поддерживаемые протоколы обмена с бортовыми контроллерами и список совместимого оборудования приведены во вложении.", - "visibility": "DEPARTMENTS", + "key": "fabrics", + "category": "fabrics", + "title": "Каталог тканей 2026", + "description": "Портьерные и тюлевые ткани в наличии, цены за метр", + "content": "Портьерные: блэкаут «Норд» (100% затемнение, 1 490 ₽/м, 14 цветов), лён смесовый (890 ₽/м, 9 цветов), бархат «Верона» (2 190 ₽/м, 6 цветов), канвас (690 ₽/м, 12 цветов).\n\nТюль: вуаль (390 ₽/м), сетка «Грек» (520 ₽/м), лён-тюль (740 ₽/м).\n\nВсе ткани ширина 280–300 см. Образцы отправляем почтой бесплатно — до 5 штук.", "attachments": [ - "reper-telematics-spec.md" - ] + { + "file": "katalog-tkanej-2026.pdf", + "text": "katalog-tkanej-2026.txt", + "contentType": "application/pdf" + } + ], + "createdDaysAgo": 38 + }, + { + "key": "measure", + "category": "measure", + "title": "Как снять мерки самостоятельно", + "description": "Инструкция для клиента: карниз, высота, припуски", + "content": "1. Измерьте длину карниза от края до края (не окна!).\n2. Высоту измеряйте от крючка карниза (или верха трубы) до желаемой длины: до подоконника (минус 1 см), до пола (минус 1–2 см) или «в пол» с напуском (+3–5 см).\n3. Для штор на люверсах прибавьте 3 см сверху.\n4. Сфотографируйте окно целиком — консультант проверит расчёт.", + "attachments": [], + "createdDaysAgo": 36 + }, + { + "key": "delivery", + "category": "delivery", + "title": "Сроки пошива и доставка", + "description": "Пошив 5–7 рабочих дней, доставка СДЭК и курьером", + "content": "Пошив занимает 5–7 рабочих дней после согласования расчёта и оплаты. Срочный пошив (3 рабочих дня) +30% к стоимости пошива, только при наличии ткани.\n\nДоставка: по Санкт-Петербургу курьер 400 ₽ (бесплатно от 10 000 ₽), интервалы 10:00–13:00, 13:00–17:00, 17:00–20:00; курьер звонит за час. По России — СДЭК до пункта выдачи или до двери, 3–5 рабочих дней с момента отправки, трек-номер приходит на почту в день отправки. Казань, Москва, Нижний Новгород — обычно 3 дня.\n\nПеренос интервала доставки — бесплатно, не позднее 18:00 накануне.", + "attachments": [ + "dostavka-i-oplata.txt" + ], + "createdDaysAgo": 35 + }, + { + "key": "returns", + "category": "returns", + "title": "Возврат и обмен", + "description": "14 дней, условия для изделий по индивидуальным размерам", + "content": "Готовые изделия (подушки, покрывала из каталога) — возврат 14 дней без объяснения причин, если не были в использовании.\n\nИзделия по индивидуальным размерам возврату не подлежат (ст. 26.1 ЗоЗПП), кроме брака и несоответствия согласованному заказу: неверный цвет или размер, дефекты пошива. В этом случае — бесплатная переделка или обмен на другой цвет ткани в наличии, срок 5–7 рабочих дней; деньги возвращаем по желанию клиента в течение 10 дней.\n\nДля заявки нужны номер заказа и фото изделия. Заявку оформляет сотрудник, не AI.", + "attachments": [], + "createdDaysAgo": 33 + }, + { + "key": "care", + "category": "fabrics", + "title": "Уход за шторами", + "description": "Стирка, глажка, чистка по типам тканей", + "content": "Блэкаут: деликатная стирка 30°, без отжима, гладить с изнанки на минимальной температуре. Лён: стирка 40°, усадка до 3% заложена в расчёт. Бархат: только сухая чистка. Тюль: стирка 30° в мешке, сушить на карнизе.", + "attachments": [], + "createdDaysAgo": 20 + }, + { + "key": "account", + "category": "site", + "title": "Личный кабинет на сайте", + "description": "Регистрация, восстановление пароля, статусы заказов", + "content": "Вход по e-mail. Письмо для сброса пароля приходит с адреса no-reply@atelie-nord.ru в течение 5 минут; если письма нет — проверьте «Спам» и правильность адреса. Повторный запрос возможен через 10 минут.\n\nСтатусы заказа в кабинете: «Ожидает оплаты» → «В производстве» → «Готов» → «Передан в доставку» → «Доставлен». Изменить адрес доставки можно до статуса «Передан в доставку» — через сотрудника поддержки.", + "attachments": [], + "createdDaysAgo": 28 + }, + { + "key": "old_prices", + "category": "measure", + "title": "Прайс 2024 (устарел)", + "description": "Старые цены — отключено, оставлено для истории", + "content": "Пошив 380 ₽/м, тюль 290 ₽/м. Не использовать.", + "attachments": [], + "isEnabled": false, + "createdDaysAgo": 400 } ], - "llmInvocations": [ - { - "channel": "vektor-sales", - "product": "vektor", - "purpose": "demo-chat-sales", - "model": "anthropic/claude-sonnet-4.6", - "promptTokens": 5200, - "completionTokens": 1380, - "costMicros": 312000, - "status": "SUCCESS" + "usage": { + "_comment": "История вызовов LLM за 30 дней для раздела «Использование AI»: генерируется по шаблону.", + "days": 30, + "chatPerDay": { + "consultant": [ + 6, + 14 + ], + "support": [ + 3, + 9 + ] }, - { - "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" - } - ] + "embeddingPerDay": [ + 0, + 2 + ], + "model": "openai/gpt-4o-mini", + "promptTokens": [ + 600, + 1800 + ], + "completionTokens": [ + 80, + 260 + ], + "costMicrosPerToken": 0.6, + "failuresTotal": 3 + } } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/conversations.json b/apps/backend/hub_platform/identity/demo_seed/data/conversations.json index 14b7ecc..1f0b38e 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/conversations.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/conversations.json @@ -1,208 +1,840 @@ { "schemaVersion": 1, + "_comment": "Диалоги «Ателье Норд» по кадрам дизайн-базлайна v2. Время — относительное (minutesAgo/daysAgo) от момента установки. Аватары контактов — media/avatars, отдаются публичным роутом демо-медиа. Голосовые: file — из media/voice (см. README сида), если файла нет — сообщение пропускается с предупреждением.", "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" } + { + "key": "maria", + "name": "Мария Соколова", + "phone": "+7 917 254-18-40", + "avatar": "maria-sokolova.jpg", + "createdDaysAgo": 34 + }, + { + "key": "irina", + "name": "Ирина Левина", + "phone": "+7 921 118-77-03", + "avatar": "irina-levina.jpg", + "createdDaysAgo": 60 + }, + { + "key": "olga", + "name": "Ольга Мельник", + "phone": "", + "avatar": "olga-melnik.jpg", + "createdDaysAgo": 12 + }, + { + "key": "sergey", + "name": "Сергей Крылов", + "phone": "+7 903 445-90-12", + "avatar": "sergey-krylov.jpg", + "createdDaysAgo": 9 + }, + { + "key": "darya", + "name": "Дарья Волкова", + "phone": "", + "avatar": "darya-volkova.jpg", + "createdDaysAgo": 2 + }, + { + "key": "viktor", + "name": "Виктор Панов · ООО «Дом текстиля»", + "phone": "+7 812 640-22-10", + "createdDaysAgo": 90 + }, + { + "key": "polina", + "name": "Полина Ракитина", + "phone": "", + "createdDaysAgo": 1 + }, + { + "key": "spammer", + "name": "", + "phone": "", + "createdDaysAgo": 0 + } ], "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" } + { + "key": "maria_tg", + "contact": "maria", + "connection": "tg", + "externalUserId": "482913001", + "displayName": "Мария Соколова", + "username": "m_sokolova" + }, + { + "key": "irina_tg", + "contact": "irina", + "connection": "tg", + "externalUserId": "482913002", + "displayName": "Ирина Л.", + "username": "irina_levina" + }, + { + "key": "irina_max", + "contact": "irina", + "connection": "max", + "externalUserId": "max-77103", + "displayName": "Ирина Левина" + }, + { + "key": "olga_max", + "contact": "olga", + "connection": "max", + "externalUserId": "max-88214", + "displayName": "Ольга Мельник" + }, + { + "key": "sergey_web", + "contact": "sergey", + "connection": "web_account", + "externalUserId": "web-sess-4402a", + "displayName": "Сергей Крылов" + }, + { + "key": "darya_tg", + "contact": "darya", + "connection": "tg", + "externalUserId": "482913005", + "displayName": "Даша", + "username": "dvolkova" + }, + { + "key": "viktor_tg", + "contact": "viktor", + "connection": "tg", + "externalUserId": "482913006", + "displayName": "Виктор Панов", + "username": "v_panov" + }, + { + "key": "polina_email", + "contact": "polina", + "connection": "email", + "externalUserId": "polina.rakitina@mail.ru", + "displayName": "Полина Ракитина" + }, + { + "key": "spam_tg", + "contact": "spammer", + "connection": "tg", + "externalUserId": "900000001", + "displayName": "Кредит Онлайн", + "username": "credit_fast_24" + } + ], + "labels": [ + { + "key": "urgent", + "name": "Срочно", + "color": "#d6336c" + }, + { + "key": "vip", + "name": "VIP", + "color": "#b8860b" + }, + { + "key": "return", + "name": "Возврат", + "color": "#7c3aed" + }, + { + "key": "delivery", + "name": "Доставка", + "color": "#1677ff" + }, + { + "key": "wholesale", + "name": "Оптовый", + "color": "#0f9d58" + } + ], + "replyTemplates": [ + { + "title": "Приветствие", + "text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт." + }, + { + "title": "Сроки пошива", + "text": "Пошив занимает 5–7 рабочих дней после оплаты. Срочный вариант за 3 дня возможен при наличии ткани, +30% к стоимости пошива." + }, + { + "title": "Доставка СДЭК", + "text": "Отправляем СДЭК до пункта выдачи или до двери, 3–5 рабочих дней. Трек-номер придёт на почту в день отправки до 19:00." + }, + { + "title": "Перенос доставки", + "text": "Перенесли доставку на {дата}, интервал {интервал}. Курьер позвонит за час. Подтвердите, пожалуйста, что интервал подходит." + }, + { + "title": "Возврат — запрос фото", + "text": "Сожалею, что так вышло. Пришлите, пожалуйста, номер заказа и фото изделия при дневном свете — оформлю заявку на переделку или обмен." + }, + { + "title": "Образцы тканей", + "text": "Отправим до 5 образцов почтой бесплатно. Напишите, какие ткани и цвета интересуют, и адрес с индексом." + }, + { + "title": "Закрытие диалога", + "text": "Рада была помочь! Если появятся вопросы — пишите, отвечу в рабочее время с 10:00 до 20:00." + } ], "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", + "key": "maria_delivery", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", "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": "" }, + "assignedOperator": "anna", + "group": "consultants", + "priority": "HIGH", + "labels": [ + "delivery", + "urgent" + ], + "note": "Заказ 4471 — комплект штор. Постоянный клиент с августа, предпочитает Telegram.", + "createdMinutesAgo": 95, + "lastActivityMinutesAgo": 4, + "readBy": [ + "anna" + ], + "previousConversation": "maria_first", "messages": [ { - "externalId": "", "author": "CONTACT", - "text": "Добрый день!\n\nПодскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.\n\nС уважением,\nФёдор Григорьев\nООО «ТехноСофт»", - "contentHtml": "

Добрый день!

Подскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.

С уважением,
Фёдор Григорьев
ООО «ТехноСофт»

" + "text": "Здравствуйте! Заказ 4471 — когда доставка? Обещали до среды.", + "minutesAgo": 95 + }, + { + "author": "AI", + "text": "Здравствуйте, Мария. Доставка в Казань занимает 3–5 рабочих дней с момента отправки. Заказ 4471 сейчас на этапе пошива — отправим ориентировочно в четверг.", + "minutesAgo": 94 + }, + { + "author": "CONTACT", + "text": "А можно ускорить? Нужно к субботе.", + "minutesAgo": 90 + }, + { + "author": "AI", + "text": "Понимаю. Ускоренная доставка возможна, но её условия лучше уточнить у специалиста — передаю вопрос коллегам.", + "minutesAgo": 90 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: клиент просит ускорить доставку", + "minutesAgo": 90 }, { - "externalId": "demo-email-reply-1", "author": "OPERATOR", - "authorUser": "support_engineer", - "text": "Здравствуйте, Фёдор! Продление поддержки на 12 месяцев для лицензии В-4712 — 189 000 ₽. Подготовлю счёт, на какую почту направить?" + "authorUser": "anna", + "text": "Мария, добрый день! Анна Ким, консультант. Уточняю у производства, можно ли отправить завтра.", + "minutesAgo": 48 + }, + { + "author": "SYSTEM", + "text": "Анна Ким взяла диалог", + "minutesAgo": 48 + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "maria-hurry.ogg", + "contentType": "audio/ogg", + "durationSeconds": 14, + "transcriptStatus": "READY", + "transcript": "Анна, спасибо! Если получится отправить завтра, будет идеально — в субботу приезжают гости, хочу успеть повесить." + }, + "minutesAgo": 30 + }, + { + "author": "OPERATOR", + "authorUser": "anna", + "text": "Здравствуйте, Мария. Заказ 4471 собран и передан курьеру, доставка сегодня до 20:00. Курьер позвонит за час.", + "minutesAgo": 6 + }, + { + "author": "CONTACT", + "text": "Спасибо, понял", + "minutesAgo": 4 + } + ] + }, + { + "key": "irina_status", + "agent": "consultant", + "connection": "tg", + "contact": "irina", + "identity": "irina_tg", + "externalChatId": "482913002", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "support", + "priority": "MEDIUM", + "labels": [ + "delivery" + ], + "note": "Постоянный клиент, заказ 4390. Просила писать на почту, не звонить.", + "createdMinutesAgo": 130, + "lastActivityMinutesAgo": 110, + "readBy": [], + "previousConversation": "irina_first", + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте. Заказ 4390 оплачен неделю назад, статус не меняется. Что происходит?", + "minutesAgo": 130 + }, + { + "author": "AI", + "text": "Здравствуйте, Ирина. Вижу заказ 4390 в статусе «В производстве». Уточню сроки у коллег — передаю вопрос.", + "minutesAgo": 129 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: вопрос по статусу заказа", + "minutesAgo": 129 + }, + { + "author": "CONTACT", + "text": "Хочу поговорить с человеком", + "minutesAgo": 110 + } + ] + }, + { + "key": "olga_return", + "agent": "consultant", + "connection": "max", + "contact": "olga", + "identity": "olga_max", + "externalChatId": "max-88214", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "consultants", + "priority": "HIGH", + "labels": [ + "return" + ], + "note": "", + "createdMinutesAgo": 22, + "lastActivityMinutesAgo": 6, + "readBy": [], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте! Получила шторы, но цвет не тот, что на сайте.", + "minutesAgo": 22 + }, + { + "author": "AI", + "text": "Здравствуйте, Ольга. Сожалею. Возврат возможен в течение 14 дней — передаю вопрос сотруднику.", + "minutesAgo": 21 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: жалоба на качество", + "minutesAgo": 21 + }, + { + "author": "CONTACT", + "text": "Можно оформить возврат? Я бы хотела заменить на бежевый, если он есть в наличии, а не возвращать деньги.", + "minutesAgo": 12 + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "olga-color.ogg", + "contentType": "audio/ogg", + "durationSeconds": 9, + "transcriptStatus": "NONE", + "transcript": "" + }, + "minutesAgo": 6 + } + ] + }, + { + "key": "sergey_address", + "agent": "support", + "connection": "web_account", + "contact": "sergey", + "identity": "sergey_web", + "externalChatId": "web-sess-4402a", + "lifecycle": "OPEN", + "controlMode": "AI", + "expectedResponder": "CUSTOMER", + "group": "support", + "priority": "NONE", + "labels": [ + "delivery" + ], + "note": "", + "createdMinutesAgo": 40, + "lastActivityMinutesAgo": 33, + "readBy": [ + "artem" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте, хочу изменить адрес доставки по заказу 4402", + "minutesAgo": 40 + }, + { + "author": "AI", + "text": "Здравствуйте, Сергей. Напишите новый адрес — поменяю.", + "minutesAgo": 40 + }, + { + "author": "CONTACT", + "text": "Казань, ул. Баумана 12, кв. 40", + "minutesAgo": 36 + }, + { + "author": "AI", + "text": "Сейчас уточню у коллег, какой адрес указан в кабинете.", + "minutesAgo": 36 + }, + { + "author": "AI", + "text": "Готово, адрес обновлён. Хорошего дня!", + "minutesAgo": 34 + }, + { + "author": "CONTACT", + "kind": "contact", + "text": "+7 903 445-90-12", + "minutesAgo": 33 + } + ], + "supportIdentitySnapshot": "sergey_account" + }, + { + "key": "darya_measure", + "agent": "consultant", + "connection": "tg", + "contact": "darya", + "identity": "darya_tg", + "externalChatId": "482913005", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "dmitry", + "group": "consultants", + "priority": "LOW", + "labels": [], + "note": "Спрашивала о тканях и сроках пошива. Передан каталог.", + "createdMinutesAgo": 1500, + "lastActivityMinutesAgo": 1380, + "readBy": [ + "dmitry" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Добрый день. У вас можно заказать шторы по своим размерам?", + "minutesAgo": 1500 + }, + { + "author": "AI", + "text": "Добрый день! Да, шьём по индивидуальным размерам. Подскажите ширину и высоту окна — рассчитаю стоимость.", + "minutesAgo": 1499 + }, + { + "author": "CONTACT", + "text": "Карниз 3 метра, высота 2,6. Хочу блэкаут, серый или графит.", + "minutesAgo": 1490 + }, + { + "author": "AI", + "text": "Блэкаут «Норд» есть в графите и дымчато-сером. Две полотна на карниз 3 м с коэффициентом 1.5 — примерно 9 270 ₽ с пошивом и лентой. Передаю консультанту, он пришлёт образцы.", + "minutesAgo": 1489 + }, + { + "author": "SYSTEM", + "text": "Дмитрий Орлов взял диалог", + "minutesAgo": 1420 + }, + { + "author": "OPERATOR", + "authorUser": "dmitry", + "kind": "voice", + "voice": { + "file": "dmitry-samples.ogg", + "contentType": "audio/ogg", + "durationSeconds": 18, + "transcriptStatus": "READY", + "transcript": "Дарья, добрый день, это Дмитрий из ателье. Отправлю вам три образца блэкаута: графит, дымчатый и тёмно-серый. Напишите адрес с индексом, уйдут завтра почтой." + }, + "minutesAgo": 1400 + }, + { + "author": "CONTACT", + "text": "Уточняла размеры окон, получила расчёт стоимости, оформила заказ.", + "minutesAgo": 1380 + } + ] + }, + { + "key": "guest_curtains", + "agent": "consultant", + "connection": "web", + "webGuest": true, + "lifecycle": "OPEN", + "controlMode": "AI", + "expectedResponder": "CUSTOMER", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdMinutesAgo": 9, + "lastActivityMinutesAgo": 8, + "readBy": [], + "messages": [ + { + "author": "CONTACT", + "text": "Сколько стоит тюль на окно 2 метра?", + "minutesAgo": 9 + }, + { + "author": "AI", + "text": "Здравствуйте! Тюль-вуаль на карниз 2 м с коэффициентом 2.0 — 4 м ткани по 390 ₽ плюс пошив 350 ₽/м: около 2 960 ₽. Минимальный заказ 3 000 ₽ — можно добавить шторную ленту. Подсказать по другим тканям?", + "minutesAgo": 8 + } + ] + }, + { + "key": "polina_password", + "agent": "support", + "connection": "email", + "contact": "polina", + "identity": "polina_email", + "externalChatId": "polina.rakitina@mail.ru", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "support", + "priority": "MEDIUM", + "labels": [], + "note": "", + "createdMinutesAgo": 240, + "lastActivityMinutesAgo": 200, + "readBy": [], + "transportMeta": { + "subject": "Не приходит письмо для сброса пароля" + }, + "messages": [ + { + "author": "CONTACT", + "text": "Не приходит письмо для сброса пароля в личном кабинете", + "contentHtml": "

Здравствуйте!

Пытаюсь восстановить пароль от личного кабинета на atelie-nord.ru — письмо не приходит уже час. Адрес правильный, проверила.

С уважением,
Полина Ракитина

", + "minutesAgo": 240 + }, + { + "author": "AI", + "text": "Проверьте папку «Спам». Если письма нет, передам запрос сотруднику.", + "contentHtml": "

Здравствуйте, Полина!

Письмо приходит с адреса no-reply@atelie-nord.ru в течение 5 минут. Проверьте, пожалуйста, папку «Спам». Если письма нет — передам запрос сотруднику.

", + "minutesAgo": 238 + }, + { + "author": "CONTACT", + "text": "В спаме тоже нет. Помогите, пожалуйста.", + "contentHtml": "

В спаме тоже нет. Помогите, пожалуйста.

", + "minutesAgo": 200 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: клиент просит сотрудника", + "minutesAgo": 200 + } + ] + }, + { + "key": "viktor_wholesale", + "agent": "consultant", + "connection": "tg", + "contact": "viktor", + "identity": "viktor_tg", + "externalChatId": "482913006", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "svetlana", + "group": "consultants", + "priority": "LOW", + "labels": [ + "vip", + "wholesale" + ], + "note": "Закупщик текстиля для гостиницы. Просил писать на почту, не звонить.", + "createdMinutesAgo": 4300, + "lastActivityMinutesAgo": 2900, + "readBy": [ + "svetlana", + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Добрый день. Гостиница «Дом текстиля», 46 номеров. Нужны блэкаут и тюль на все окна, единый размер 1.8 × 2.5. Есть оптовые условия?", + "minutesAgo": 4300 + }, + { + "author": "AI", + "text": "Добрый день, Виктор! Оптовые условия обсуждает старший консультант — передаю диалог.", + "minutesAgo": 4299 + }, + { + "author": "SYSTEM", + "text": "Светлана Петрова взяла диалог", + "minutesAgo": 4200 + }, + { + "author": "OPERATOR", + "authorUser": "svetlana", + "text": "Виктор, здравствуйте! Светлана Петрова. На 46 комплектов даём 12% на ткань и пошив, срок 3 недели. Отправлю КП на почту сегодня.", + "minutesAgo": 4190 + }, + { + "author": "CONTACT", + "text": "Отлично, жду КП. Почта на визитке.", + "minutesAgo": 2900 + } + ] + }, + { + "key": "maria_first", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "anna", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdDaysAgo": 34, + "lastActivityDaysAgo": 33, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте! Заказала у вас комплект штор, номер заказа 4471. Подскажите, сколько занимает доставка в Казань?", + "daysAgo": 34 + }, + { + "author": "AI", + "text": "Здравствуйте, Мария! Доставка в Казань СДЭК — 3–5 рабочих дней с момента отправки, трек-номер придёт на почту.", + "daysAgo": 34 + }, + { + "author": "CONTACT", + "text": "Спасибо! А можно перенести на завтра утро? Сегодня меня не будет дома.", + "daysAgo": 33 + }, + { + "author": "SYSTEM", + "text": "Анна Ким взяла диалог", + "daysAgo": 33 + }, + { + "author": "OPERATOR", + "authorUser": "anna", + "text": "Конечно. Перенесла доставку на завтра, 10:00–13:00. Подтвердите, пожалуйста, что интервал подходит.", + "daysAgo": 33 + }, + { + "author": "CONTACT", + "text": "Подходит, спасибо!", + "daysAgo": 33 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · вела Анна Ким", + "daysAgo": 33 + } + ] + }, + { + "key": "irina_first", + "agent": "consultant", + "connection": "max", + "contact": "irina", + "identity": "irina_max", + "externalChatId": "max-77103", + "lifecycle": "CLOSED", + "controlMode": "AI", + "expectedResponder": "NOBODY", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdDaysAgo": 60, + "lastActivityDaysAgo": 60, + "readBy": [ + "anna", + "dmitry" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте, какие ткани есть для спальни, чтобы полностью затемняли?", + "daysAgo": 60 + }, + { + "author": "AI", + "text": "Здравствуйте, Ирина! Для полного затемнения подойдёт блэкаут «Норд» — 100% затемнение, 14 цветов, 1 490 ₽/м. Могу отправить образцы почтой бесплатно.", + "daysAgo": 60 + }, + { + "author": "CONTACT", + "text": "Спасибо, понятно, подумаю.", + "daysAgo": 60 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · AI", + "daysAgo": 59 + } + ] + }, + { + "key": "olga_archived", + "agent": "consultant", + "connection": "max", + "contact": "olga", + "identity": "olga_max", + "externalChatId": "max-88214", + "lifecycle": "CLOSED", + "controlMode": "AI", + "expectedResponder": "NOBODY", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "archivedDaysAgo": 5, + "createdDaysAgo": 12, + "lastActivityDaysAgo": 12, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте, работаете ли вы в выходные?", + "daysAgo": 12 + }, + { + "author": "AI", + "text": "Здравствуйте! Консультанты отвечают ежедневно с 10:00 до 20:00, производство — по будням.", + "daysAgo": 12 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · AI", + "daysAgo": 11 + } + ] + }, + { + "key": "spam_credit", + "agent": "consultant", + "connection": "tg", + "contact": "spammer", + "identity": "spam_tg", + "externalChatId": "900000001", + "lifecycle": "SPAM", + "controlMode": "PAUSED", + "expectedResponder": "NOBODY", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdMinutesAgo": 600, + "lastActivityMinutesAgo": 600, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "ВЫГОДНЫЙ КРЕДИТ ДЛЯ БИЗНЕСА до 5 000 000 ₽ без справок! Жми: credit-fast24.example", + "minutesAgo": 600 + }, + { + "author": "SYSTEM", + "text": "Анна Ким пометила диалог как спам", + "minutesAgo": 590 + } + ] + }, + { + "key": "portal_sergey", + "agent": "support", + "connection": "web_account", + "contact": "sergey", + "identity": "sergey_web", + "supportIdentitySnapshot": "sergey_account", + "externalChatId": "portal-sergey-1", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "artem", + "group": "support", + "priority": "NONE", + "labels": [], + "note": "Авторизованное обращение из личного кабинета (портал поддержки).", + "createdDaysAgo": 6, + "lastActivityDaysAgo": 6, + "readBy": [ + "artem" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Оплатил заказ 4402 по СБП, а статус «Ожидает оплаты». Чек прикладываю.", + "daysAgo": 6 + }, + { + "author": "AI", + "text": "Здравствуйте, Сергей. Платёж по СБП отражается до 30 минут. Если статус не изменится — передам сотруднику.", + "daysAgo": 6 + }, + { + "author": "SYSTEM", + "text": "Артём Гусев взял диалог", + "daysAgo": 6 + }, + { + "author": "OPERATOR", + "authorUser": "artem", + "text": "Сергей, платёж нашёл и провёл вручную — заказ 4402 в статусе «В производстве». Спасибо за терпение!", + "daysAgo": 6 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · вёл Артём Гусев", + "daysAgo": 6 } ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/darya-volkova.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/darya-volkova.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8fdef99286740c7ca60a9372fc10c3179e5a0143 GIT binary patch literal 37821 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 WvNi5l{KZ6sX9Z)HbdQ*z-~ZVnll*i5 literal 0 HcmV?d00001 diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0e1b6986197a8f52923de65b218225dca83f773a GIT binary patch literal 33379 zcmb4J^-~;8v|ZeSTY@gIK=1&IyIXJ%7F-s00wlOE8YGLmyDt#j-QC?ScoJT|s`vhY zce|$MhtqSWtEOe{Irnwxbqj!}D61d~KtKQh5dJm5>ncDBfQpQaf{cucf`WpIii(DT zgMopLjzNHpjfq1{Kte)HKtx1JMnggRmiiqL5hWufH7y-IJv|8p6YF~r3k`@K^dAYr zzeh39G4L@k@Ih~h-h%$$@!AEzM@Ix80+0}B0f_hrNcae^eE^Do<3vIDZ@B*#2mnMR zWE501^na)X9smIe5g7>u6$J?y5fKsTU&ntaJ_-R1r-ZsGDj}^)AQ6{jLT)XcM)&kK zn%NaGx9c@XN;8NgkKQe~?jOawf8_tg|Iz=uBmDP*e-H@&KRQID|E~YV5fJfdIFSe> zXw{JkxlCOoUsnKc{$YstNcaFTz!_4aU{NtjGM>7Wg=4_VF(7$Hd@D$T2pzdufe3>n z9q7J{7r0f_<}N$@X&+!K9a3rGH-(VL$7Fo0j})l5riQ+8gwLnILT?s;ytN91PSI*~ zBr2|}0WtU_SMtsluq}h*s1z|rmX1EVK}W-2c8=~;9e9dQQmAcZGrCROmBU*L7p1`> zMr#jgJC0+fb6KFa$eU@k>Vd0v;>^7}Y>1~rOix)=P zuo9-|Ept<%pRTi_i{R5Hgc7u>AGo26uWhu2Y_k|TnBl6GVPNQEJC)Rls3D`v9yiz` zl#DH{6QDYWYwy@C%bGo9P#mTI)Tyz-H72X5Fd&Jd5Jj9B6x9{Z5I~> z@3oSZoxqMQ{BA^+*WJFdWL71Sni%|%#BKi|NgLW`pdqJCDC7kM42JiiuSGW`&8@p! zU|U8446H(ynZqUo#P@1lE62>Mvm3`N`N|90_)$~Rza)GsOMK%RhDfcf4d&P;?_z5% zTm(|Bh5z(ai51MD8hkYPV69BF??DwPx3Ul(Qx`$O$L7N7^$O5z&O5ewQPL&!9p1X6 z1%8tEcm;^)#y)Tb`t9b9WvfOlsTOp-eC63|kNKDj!!pa-YUIBNAhzGT|BeYB5G5x`rkD)U@VvauE zuK-nf#1)`yStSDvr2zsYEbkn+hCi-vI$n0%OrtpLQum(R0`m)rIDs6qoHUY!nmka> zUG^9hkn5lUr_tgaro@*Im10kn6Awc6QUC=3g8Y-v#iP&@^2mWGSd2t7K@1leFidBC z^*S0**b#OD3P=(eUvek}9eEGYc^Od#Ii4sUE>968zl;=w4owutq+v^hqgY^ilYXSS z%|f|3cB?}~30pYPwzbn6TBlho<%P&;CfL$Z9{?_dE8XCxZrDxVh|%M0wGk-He#{JHwm(`=QeEA z^z`5C_xfyK0XIwfLiSRtwRG9zu2*Q$R_v#>8Xu3w*lk@<*CY^>=jTK}4Qh`Z5<2rE z6(^cUpm8?dT2g}NwelSb>8sj4tu5@%-eHb8L1Ww#1-mQhJ3OgNl}Ko_T!_eCEimkyKf7PEjwE>kaCTL-QVD}h(^Vv*{duKs{)tDc&W;q;YCp~aU&FFUL-p<5nKhcRn?niDahuel!1L&Iqx=(#UtiJNk_ zQQ>MROmN6P114F*Cm+%%eiWcjN$9AKdPEWUZ{sXOjYO`9N5?rPiWf(p#$p7Om}1yx zNJtJLqVT6PBnA-8=Fzht!BMZo^VHtc);XU9Oq09ltHarp>wG1V;1YNQ;%a1|C^Y11 zQwv0p5hChQ2rINAkVsqtFYrrVBCmLmP(l1OKm~E11?0yIFdF0HojOP z-E$K=$_TMm`$>12zEq)&XlcQI4!84y zHd#mVF%tB@M%`R%Iv3Kq$dz!uT>wLw{_RnvpWSw2c_}k?&9rCi-%eVVA#>@Om{IhW z&_z{h91nd+PxvfBA#)K4S>@YRF_e?k>0?N&Lk(;3x*o7cZ-UsD9MC&zaN8ZM83d>+ z>D}Wk82;R(&s*cXQFIH>jwtgWTBeezbQ{*u&QHu86e{!*UuUnBQI7y0ShV^eifLm< zE3ZOu^J?Fm3-Ygy(bN8VJ3)v0QbE864RD<)8ptb&`%~P2Jt{0U=UAdCP*u|=^C+bA z`vG%1H;&gcRRXx&wMK2SZgt$+ICM(O)@CvFSEGEv^m|%FN3g}#{n7SI5u8%&h6d-5 z1x#0}Hm@Iclcpf&F3AOk^Gd6KvVh*gn97m2aubrhBh-@5rOcRIH+pX8UlW=WF~F;=)(9 z+w^u{0SS1^#kYYE&9JA9a6C8qi@kb!@@50#Bcr7AXj>|}guxFp57WH~x)hBbnyg;4 z%VBw2Yg9Cdn8RJQ^2|tPVLEWeprgjsbP;~{8v`oBx=Z#o*6HSHM6h%mfgmki9UI25 z9uOH6DLF*JBNZjSOi+f1LQZcfrLJUwiL6Me0q30t@G8HL53B=(P75)7#|pB=7zT#& z+a=OiDp^R%HzEb1yT}0XSZGmt{>^TEVAOkn>jARquQ zQMPAM-WwaAVNp1hWmRQHE<`P54NaiA>98p4+d-K@VUowtH#H$0b1n(w0{L6BZQlTX zoNSMM9%A;=Bvy-+zl<#WmG(=a2I(2C6QZ=r=QAh#OCwhC>tvf>RE*HH4>z8^fEMu# z-e_GNrhglKDdU<`jvP+TdfVNU{>AP6#KWSJ)p`w|@`FyUuc=KeNmyreOQ@-)rm09b zNxfL>O3U-t6P>L$1L=KRq_CHG335$Lw^bO>4=VQMB?Vou_a}v7*Z$x9>3AA|ESs z^P}FDez@i_SOzUzp*cWoB)6^w=#hs5NYM zsREC+z6HAS|sV$D3q%V-Yy~ZZH+$WTeNzmbAZNjCKTNY%pic%-q8~C#f6co@- zt^_rnUu5yoIT;(<>7BSCsA^z1^;{U22suvN!zzPYy~oU*X>};7ZMR+dsQebIw$hfW z3+yYLzZ>)ozNxmo-TCQNkok+Sc3?t?XEVc9L^sbh^AR0tW!JsaX)sW znqolQ1b<@nWYNZ8S|10}Fh>QfNHew`6-M?7c2xa4`%;N+<)CG{vvrN;nY=vEw;+#< z)FR}5N{c`uP8+X{!BVV`NQ}J>vJVu;dtVS(;HqMC5P<BG8dw$#K<@Cu#wb$biJB5#=l@pz%P> zYWL|pHo-*Y00|r#ZAxsqgYV0gb9YDWgw5-@srY?aSQT<%T^oY|R7%sGCF}V_4dE;i z5V>vKiQQ`-ga>2L6H-xPkr7PcRydWKetot%j#3%6@D}#2*!#~bAS-rR{Das~cin+C@PT$6HDx;ZC@;97fH#U|q>uisvpJiF^ zr0qZszbH7m+*nGwk6#8KDjWm$!d`HkM5Ru?usBBYCZ^;1N+He5L{CSJto=YT8w&Sp{qHU=%d@5|9tLqwK{LH$R>Czv% z!l%HvtpTouI{iGf6As#A`S64rq4;Ffm-4r~ISb}#vop}_eBS*E5Uh9w{7P*hC&lw1 zV|{7<3(E1>3y_+){Jhj6aN$1=+xkhV?6r4?n9`Aci8K+h*njC90+v7jm-$A%)|9sra z#+qh2sUqEQd9^RxDH#b(#zQmQx1HW3#H9_x{&d)9b9mi;Y~B$3pqj#A!C+%(MBa?% zGF7RVV*7*pAU~ms-mebVTNkieqCOJ$d-$l@1UHIFvsm?4s z(aG1vv&D{r-zT(4J8L$r&Y+=L6@)E-Ju_)KxBsYE!)Dm!@K{OmS1?qqV{T#uW5m6a z9v|!~$kCP3hwt*0F7L=0v#749!GIT2g2i*}kpKM*0#S$% zT}R^Uc;%5j#SEIcX50PeM`6Wnu(k!+-`ysi5EUcq%hZC zzk>2A9t&zvR=}8eVnQP0;Do)p3Wc6>ksjtcBU= zcc3P>E|Z|uJ}MyRHc{W&2d_!;#-JE}OJ6X|dSXoxV zEn7r_LGBuKQNQl!JlS9DVJT%}o0%NE>HR2a;eHnMVQ3XX4)z~@1@PVIFfonUPkC>6 zH9r+?%2)~7-&kVv((P^TDbsoWZ)mv{Q6rZH;y_NL{x1nJ^uKB_b#2oFcHnfQrM8xci_n9NfTnwKn@z81r})L2%D04IPFm zxIb6v02}Bb)*)LijUiKW#w8dn$nDW1vu1Y_4f$x z*lDaPPD98E_)w%zct@yy!@ix_yoAnbPd%u*0E25u)|fZC~v(deQG^ZoCOb@E=Z&@j<>*le?eaE z(!Zr{s%i%YKokDp{niMf#uH67W<8_SYx07a!#T!@g@w97O(zVYJ1U4stUE6Dw-6}V zZtc+*=8++h`Ha~g;Q?zNm1$u4Yhx1BNS>-}dgLGfgZ&y@LdPoxtsR zTjsmi*Xo*kfj`%EeGIvvBsNpA$AN2X=EMAKHLm7K)p@q0;&YMGH{v7 z6?A>Pz;3!?m;$9T90*w>8*P(ly)hR1^YI+1I7N|HuZ_PmQYncuvB+#(@LN zIT2@zadW+ju`8G~$-Ep}k{K2@^k6(__2C#ap?DrFi@ojrD1U_oUlU7CkJ2U`PHy-1 z4{EX;S!OnNH1Q%%{)jE3?$%`6D&k#XPRaJEu$b-h=Z8%Aa$b>pU9SRZ2UCp*e|G1v zc7eH6#-R;*&CSl&Zit%F*{?hPq%^c}kW?y*zo>%UP?q(euE9E(+A5!9cIQjEI(|tB z&`%Ig;6_8^+pclEFmHw9d`cFHvG>+k1`AHdaP@gfSu(9gH9*)x^ zkw!4PP2t1RphW@zW8v@`e0R@njl3~*R8`?0s+I^x4|UQ#L*`w|r+yocW-qP?bUX zyK1II@8E`6{DXYW-zUbOj1R3OAuI5W6m;+`#Hq-}e1BA*@YS zvaUcgqLjAIFsH-E z$Q(Ho&Nu|DpiLv&rNVM)`+aQNUpX_Le>uH@;-Jleco$uA^;%HrJ%7S-7dRiKCaFc+ zadUT?&b_EBQ*90Hr$Nw)pb}dN(IFyi1&|szw~kQ@T|7}9o1hM$h#nu-4`2#AQ7X!* zcHgdZn1$(RX@(8a1|+ywS_H}<$aVn}5ff=xfD!`8YM={@RF$p3ZZ2!onnVYGj$wZ0 zJG!4DOM)f_u4_5FZNAz`xR&E>y;g>206G zZ=bGdSHC155Pnp?d<9e%WL1f68T(yws1ROT?YPDdqQ2yH;nw*RbDKkGg%WrMTm?eVX6+<>i{8UyXv-$QM?VOCyICFhAk3_H6vw za_xYLC{~7OS|?O5cFmmL31#2HT)z27Xpb1V8ZN=UfDJC{eJWK_b|WY7!FEQKzh42- zm2cBfb(#$j?*0;gGW{`FmAc3K!p7_wOm5j2)9g(V4yaFdLhlHl`C+K{m3CpxZ{+BR zDKM=C;8Al!hLRa5Y1qxu+S)s-I_(a|5Bp%?aWK-6#Xp@N)?Ku&6T=F$&nfm@g&EuiW*=Z^KrN+R&ZU# zV;g#(c8Xs(q>(AP`UZC5_b{tHvh(P}1`3|{Y{X^^d%n{rc0W*aUw0c3-1YV;{D@-_ zf}h{Ow^L_(g3Y5AE2m%moAGa`r``hPdMbc#j(Biqu5iq4Aa=KC5N57_W8P97shI+n z2fG|Z+UTbbVp>Ik0|H&wwJKJKcxSBm9*bm zmV8TYIyzka-wz^8tWL=r0ar&_uDxjlU6jwxs|zKrMdvdqB+Z5!B?1iw=HVP2d-E*{vA>`Yt%%A zB{Ww;JR~%Z*C9)@b3Rw&g>hm$%l9UD-v^GEBXJ>Z&4zZkJ*twtELA+C?@#K39nta2 zpmq9P{5F>fB7_5BYov0Vbvkkun5f?aq(ksP_;7RtbRq=>Bt<(Pi~@Zml?WsL6Ic*{ zup&UEZY2JF4r)n`vkWJBj<+B(dqB(kOi47vaqSsN{2W2Pcj}u=SjWafm`X>uHQJiPda=y{9%T%)PVe8yS zlRAa(F{Hwa-D4kPA#MiV=PMWIJ2R*OH{Uqtem7vDmgW`J*5JDGKDIBWpUz&5cxPhM zDRr);9e9=MGSYOcIV{qBty(=oH18yVzpeC)BZq^r#oYD3lyw1`ybh3xdVd*2Z01JH zm|kWsAOV{v|E6fa_ur*3+&T1DFyOy-;Kf;5_8q1p6XDTsaM@^RTH$PZs+e5W{WTYZ zPe{hsq<*mLG*{^(rBA+RQ?DoOKqX<>bA682lFlnUD>R(fd!pC~>YL*1Bo1_@{xQ+H z#AD3~0Y4R>v^oiNXgReaEYf!70-Yvb@9!g+{l4iT!* za)qAW>sb`~kB@p2`&t+42kDS`uRX5${_w-KycZrc7LA^}8Z(@G5%KS4z$Z44y+_vn z8xkaFV62=|>f@V?szTaN09hFPxrP$OZdHjSOZJm?rKqM&4dR`+) zoWn7;X_3NTdm$SgXy~8Hw;Yw)XPKX{n3&lOyU>R;{r=U`DKp);;Ii*e>f^a>6^r@; zA|PMG1xJ;j0F$T0agK;I#PQ|@kP(lFR3eMD07aRE5p}lcEaA>7Xwuzf!1u-LnOB$|lGa2q})ajo8Zp&IC;%(K^7=2}sP*|NfJND&#w*2_C@l{TU`ZE z&jh>Mn&3s9&Cb|E$4gt^?kMzU+28lqtaV`oAFJ6Q5zE1@r{VNifGw5zl@*~M|fsBLbX${{C#1Aac%AJ zmK>&U=!wsh6;ercG(ijbDiH95yKg-!#%~8>O&&V? z!a`>t$Y6+74xs!)NM3j$Sc(<6u&Z6kZG-PboD`Z_vdqm-r!}^~FmZYiv}%l+x0ioT zg8E{l;l8X~Z7Ew%WcmJGX2;)X1Mmt@B}9R7-tC_;>tIt;vJTTZB&|Wy?zt~+PUZ^l z7nhG|13C*O_t!<06Q&LAf+l|Ur4zsvey+K~k7ss8;FbNx(v}I*?#LH9mZpD>G_!2^nXN(I{zN2l*aZ%zyrqN8hf5EV+CxX`Lm3SLN{WeympXC+u@6h-knd4< z@m?w)L<(w-*m?JAPf9PhU24~>4&bFyETs+%iL&-Pe70MDQ3EA~f_3WENJFK}`pqJA zSQn@GbYR~7ad1O(f3DC`l~&jNka$fzF2>=s4$jQ|%yYv9sgEJ6;`FABwE^#6+t3PN z37wD7=?p> z#}?^3Wh@yKB55mZh<@(Y0pWv@$D7tXbDV9shtak3F#j~2`-@!y*2lC!>6`%CAbEW8 zTN$J})J!!R@*Mg1h!{ldc-kEasLu>}*ilhmXg&~aY2l(TgNYnVoTot?c>w5lT(|@e zo|@oM&<}SB@f*hkK_Dih1-?8IY|DRP<8;i|Qf*O7duTh~Jg&_ND?PD;Ik$^f4QykJ z99KQwAoHO4cW_p_WUHb>P1 zm};~rLc)Pu+zmX0luv700m6+>*}$Vu_JMrb`XZ0l^B)HdjfhslQwBUp>vmmIC$%>u z+0m~Wu7H>KMj841!1H&_B?y-4+IsJk(d4~775F}*b;QQQ=SYEk%`%;*g9dI{_LVb#=#}cz z8v^}5;f^sCN2GTYyEv&}W?_fV3-h(d8^IAKfBzv#%J<-gfu?gVWV%Fjv)Po2eb~}V zk3SuMS}6f|NTDj2y#n^ATP3TKlhrWOIM&;w#zWjF2~|AhnYRFe1r?o}54yfn=w*eZ z1kpdm{)+R_B@N>Do6ZP)IWfEzTx|2A_6TttX1x#$H}F_poZ)4}$P>u@Zd-;C}HcdhB0;I)CcNb7jAYL zs*3%WWm|3<^GXJl&Lv|r>JVDH&x_J+)Q$@^tAbDX4ZxGof${ljy3FRfy23c89y@Zw z6+Y>+Yk9pZ9{^kRxF=`q`E+NnOx(QB=C6hPl@HIK{1y|_;Ed#se56m+3K9D#)#QmC zZ#Ss?Ui3O<4b#ahuyD?KZz=HMtEih=k@5urw0Md~SV9i!_`SrrqFM<~ih_K32Z2V`F&pA$Po;4!NK4_pONrr|ehAtj(Q?XZs$ zcI|x~9elCW+|b2zxJgeM&du#~)-Cr8^PE)^w53jlBCk0*lp3={DKeJ17jGm?_%{CB zTl#{*MSzR%S8?4lv7*SpR6IAt6kA;XG_x-qQRMZCC$7ugyy{fd;tIY7Huaoi%-x>)ej);E@tzWNP zx7d_ZZ6d39RHmls+8$lw{T>kD*_1h+S5uRF$KmJjgpwfQNYrgL*XM1cx%rL2`|j>X zn6fq!jA9YX!DD{PtTT-45UawfDJ!=%|3`RASwVFo^eHSqO|KoULV3rg>n#KASuT3_ z3jnFxau&7{pdd3UySQ3ja!us;s)W$e4i21*1sYj zD`Vb+Vh$T;z-GAiu{qkB$rTmw^0gV}iGf)yPh)0gVJM8zYn)y5rDg5T*g=2U<`pofM=8bEz_Z}yx_Mf6 zaZuoEzi}hCPx3CqLwGtU{gHAL!?1}Yoe^-5WaO=*c zX3HVQZrZJ1&Lku%Ldg|TzSef9?MF=wc@7nXs~Cs1Env>BePJ=rlx3Xy&63tQr0PIu z&F8^_I(c1^b6!sVMrKo$>9_nuXzbxs?6z)?ZQg6H|FWH4cEiyfKb33!37f{JJDIV|w^)vg2)4UtCH1~&u5@0NP>?`2;-?y;rW3Z)PKSfHf8sybJ4X}#YbZrl=#s1(KgdXkr*>UsQ z^=?UlGy0ZgcK7(;#uG%+QH>_G4J;<5lA9(y>4^sh=*oQ>=UABK01eycHX(c+opXdQbpyp;I8xmWk0>212U z1|LMI=oK&&?Ozqs=wQ*qKyG;zN7MRL@y9(jvABV?w{$Gl8}Q|%bxf&wzG?(1gwuE{ z)98plG%{{+FFE(3Bap3j?TOJ-6(NqW+CkZw!{K6Rz40k|7MJ3pcJJ0WSIe`mNZ;v= zO7fQ8j|nF_%X(}t+v1+%TC^F>ro_^T3#0XzIAD696K!7g*~@mHT@Gyep^8iP+Xt~a zeqM4DrMCqh4j-p_KGtel(^v4;Iy+&Lsxab;HEDNBgqk(-yKB#E_w_UWJ<-)47p|OE zR)!T$hP@k?g0UZnv|894I*LZuo-F{K|4wv^kI0R3jy<#vvLGnsTHA%C$yqBx~##JIoP20GSaxu@-oPZdgPt$*qDLQOY$RrnN$-8WL zmT|!J(Hgd!D%`gH{3W#^d=^aTPhxm~slYog3f-T^Gvra5!zG3`?i8uFTld+UhG|0Z zw#|q?t?|zZeL!|2)djFSRF~{jfWzW>Xg7K2O$;bbYjgxwf)rDP5i{adAhG2Ic#Y3X zdTv{=lh2Ddq^_wA_oDENx zR_hYF;yvi2l_~kIwc+nuk%CH&v85czjFwQ_a%0P=t*LOY8e`JzFTGL+q#}F`nkow;Jqw$1eCjvG!O8UMi^S1{^DmOykR_>?f%3tc(FQ9nhuU-GGP0uRGy$u3!QpTh5-i^1d6jKCn}yG z+rt#;!AB(uMZ-qmWEt=lj7N49o6=p=F?kwSnjsv7iMGzNRutkX{!7wf8jeQu72SxX zjB1L+#DW{TSm))Y_~J$}s2$#gqpSH>XvMKBg*PPkE6V z3Kz8V{JH2C^5D=^Y~s5oS{_x|CNzaE;73L&m|b@CqO4)00sM?I_W5&d3tHqK5mF(2 zmCw_eF5e8X7Ai`nzO+NZzj01rPCz*AK#b%QXZwKGqBoo`sWs4 z*2^A<`#!ws;Y~y%*v;2+q#VPSR*t-GE9S_9g`p|XxnX^v*eKBR3tzXK;rSfbS{OoD zI99#yA5^L>0q(!8cV*Ai*IC+QL)L2nV_i`n&O2xz@CbbphbOtEJhtf z2l98Vi`e^j{X^T%-fZ@mF*5-dCnN_XHBNAe7Cv2@nw>5V!fZ!hCy#OxN|pzNoGVNd zwyG9yi+ytLk!#n(mh{tZqvRrHYr<+CGgw0H;OEs!o)=dJS>u>LNi<$G#~Sq(jVNSbY=Z^2BfB6t+1b` zoiT>RZi$T(W0Hzu4%DehaU^f%L0|?TC?Gx1rFMySnVi+f$r>EgLE%ls;`6~NFF%mtZ#dqnH(8EFSTKH z9keBI)0}tbExTvjhh|WA<40npGA3yH*^~_uDJW93^EX^o=JZV~G#73Z9NUc52|c{I zZ*p)JQS>?!iRP-DEZ&r7w?#|1x*Jlu zAL!1{C3$xv6kOb32OM#m?7`1Gjd#8$3_aVH8hRM0K6}s$$wf&%M~ZC?T;Gk~8s55h z4=>_ltGkV_xuy`dH8A`w){cx26L&MpN%i>Dvb)Ixp#a!m1E|EoMrMnOiyON=0)9rM zn3qI!rX}BPFUhmogFO6lRv3JtGrym*O{NuBdkG63Ggxuybm$c1Fk<*%1^O-*DiJs*lF!{JgqXk-l}m$n2)O z(ZCPC7x*L?xA%wCDR4Xk>{Ph;oh$c1XSwgeTIqJjU-4J$xHiwY`HH(UGTRg9oGOlQ zwQSD{jij2_WB2|}5tIBKiI$nlwA z0GYh7)|0l0UU_np0Lmk=ahVtb3EBCyO$uNN1MuD|f?sff4#rdbE~5W2T$#ks$v#;k zKhWS$o>RSM>v>aoaG?tAZML!GHd1hk$NIvo>SpdT0=*9dfMD{)VRasL>>&DAckBU-q z41Va}h_!#CDmk>nfEi*x2c$A)P>fi01WoUFPZlBq`C&- z%X_ieQt3-xRnT{BU8MrB3{T0Oim44atj_jz8$bV=T-aeP`c0}ze5hiq-)kp^#maA! zVn3MkQq-rk!jDwuO|txj$`x)aJLDo>k&66pp<)yYU;*t=1h>ICPRRI7#nMD!5$j;X z{BBFxV}_NZWuzXa$RSMWf<)v9WfgQPd?c+Y`)HLTE+N@d^${m01f;Qev~@Rzn+)}F z5)1drB5M`Ub2@u!L+qQPaD%TbZXN(qgIB=3+xJR$e$bC0NFiJN@9t;Zx?|WOO+q9`G;hq_wkp3u8V(i@^#| zo-nK7v`d{Nnq=%Vs#eJgKGFF}r=N8mljMLV(LY%Fj1mjH%>B_M$hCU`-xghDy(Ei~ zUr#Z^*jqu3Hj-pYT>6g zCeA#(N9_;DgJl7TAOWG z!G)32o{e!TDvXA@ct7dx91+m@tKlg~o1j|WFGsgqulvTP5s^4Y20%&R! z(+NFnF9zG~5s#TZY(1_}mSFLeewPTC(H5MYZ%@T_hjPrjLp|Zi$ebx3!1mGRz#zjq z!~4XK)h8tWmdGB6GhDlYy0<*3t*u%t<>Yt_2P3v|Rv*(;-Ml1((jC8q95FM~2{0h8 zQ4sD_5|cQlF#6AzDMAh)poN{qs+P(}C}n)vGHc zL+P~1)3eama0(u*$Lt#$W?Rm*`TMx0+r143@edGcUI79eN@w6(-oKWm~~7(3+&2sx22JDzi*QaCTx@fyN}lZhuj%9F{n!o zH*FYW({YFn&WFq4!4QPo7xv(W374$U04&5q^6SR%llIF-?{e!>Tk~OaR2>5ULu`sy zK;~fHZsx-JRr+T$?O57DagOh<)bPF(mFuU>oJWJ_Qf507ZBZS=0qiQ-<4Jq13IYY| z;qG?Ct&2C%+>U*zvvn9QHRzU;4F`!u1xj63j${&{XqA>Ya5UYm|K_rY&73Vl$uER^+9C&2>Llipv;C^kLKl6;d@hNPI+6xLEx8p$dA*GVs};LWZyO+WPcJBa zD#XTA3K#3!-pAu#im;=->>iOd%*BH6X3cZWH7t|N0BJ25HccSfM zd398wSQ@FE9O@kox#qb0=vulsPRX&=aK#w6c-Fz!7IK%m=_(#i!5{vCy54)(BRw=oeQ|_p9_2`eK=rqTz zB>=V1-_oD@kJak9+X$^q9VEVH9rV(ly&HU0Y2kuVpC3(}MgUK&25Hh~qzhv$w>moZ zCONbMwpOO~RyL?>i@94KRjD{7d7Zmbk5f+#8=K!?TOV42ue`vx1BFTL4sgm;TU9;} z5e62@wpM#%)RoJ>K9%MhOC7eyT0FE>$%)OjZx`d}eyT&G*UTKZ7qQkm0zFZ(+!>#8!b2j*duGa`8U^m_TR0 znpJ}|8>a%8kWo)2$lWsOT|nSb1vDd~fyjcI3Md_P2}l$PKtWFi>M^Q%4mbLTT6Gk2bDa&GGZ?U##IQ-Wm zhf*@3&zbSMR*{w~a~imxr_3)tT;%lQ#wgiNC`=*kHw7Vu+Z_+(~nWr0=q9V0VX0fCbS;jkziTcK5L8E~hLFV-3C`_fWB=wNbs> zYxP&_8{Nvt+An6<`RL_QR+YWhG|PdN?I-DEu@4PX0MgnYnO16}F};(Ggk;~$fU9Ch z@#B)(Ik)7;bb&|Hw4?Jp6K2HU6M z#j(f$Iu(9gIk}wX`;O$fI{b2#n6QFY-PQ-0Q#Tx^iZJKMFK%e#eUzreA#+`u!f&C~ zahQi@DrudXnx;b=eg_+_nCh{s9do-#B>w#}2D5R}35a}ho`MGk`n0$vvRz0pJ zzyJc(qQEKUd%M?3Hn#CR*>ti;qs5L{G$pGLkVAt&*EQ1yHY%ktMA>AJTH&qJ=Cvrh zJxE#@Vqgc0wO6B}pr)8*IfhU`XyeIeZ(Q-~y33CbtgOn?=QV^|BL=@UZY(N1IzJRm zX*=6+daY$qz0yPSs3Na`2}&CXDqcg#U(?)%;z0O!otJuyV=kn^6l)F&$1}m&mO~a!J{s8&K!E*$K19Fn3)>3d2!{R^iXF zbtJbJ-yu8tAG*ct(?OnS!!BKz3ysGQvfINXbp|g-JcsbAL#by6ueyF~rY>1GyCGcf z^(dJ!KaA5%;!e(Pt@(xBMo8arpoB)2VC3>~}o6>FIUpaY^0=y8HNn<_h$9 z{{T-vllGjHHz``&Q3Y+x5J>7$Cq+X|kh)jmfhvGX8=_8#1VY&&fhZGX8Dx}3p)@C5 zWuoGUO(h_K*RpUai34O3C3M@Q6cPf3&`RhW4yist8yxen7fsz|_KvOe*i7zY#AU-M zcP?k;bhk?;-Z|RY-H7A0vjLT)*mdE5nte7CStcJFBRL{_14w7Q;4XbGZhz@i#3FmU zxm@B*pxL(9{_l0@7;F^r_C>SAb3URF$C1F7Wh3ak@@~Br>O926#zgjr#oho0+iSm7 zv)W2Hap;Sk9LkX}?83bRD?Wt{d`P+luBe=d^+DkX7tM z#P0c{-Ecbj{>n2EW1VqplW(8-@>1ATLo8Rehfcgz*`#%Bbd)i)Ugf2}cH#6{^R?2^ z!67=ZmLHnA9Iuv&JqBjV2dMu5prDT{;HroZb>YLv=%whBmQE_t`bP5>BkZsq!S89+ zboh)X*3+gn40D|9WFIq9$L41JKAY5nHv<&af13DEdCciCL88D_`JTn zSGLk(C8RWxHPG^1W*=V3p%*tcURyznM^_VMW5EPm5LVR&Fz6fxZ5Nj;UhH#rHBz;% zJc_}@qHEiBSSu1@4W1C@xD#$_ojp;LGas#r#F}q^7&R1NyNg^k1S3g3RS`EG<8OP7 z*1b(BF3inBiLeWR_>Y)frb~yC<5p>LOzjsB7u1Dg+auf!Zli*R83v7gxm>G|;mnH8 z5i}9T+NpASXYwuhiaDj3z&0`mQDC;P_OcPSc{%b?MM&w~EgP-nqT8av9@YXtXk6}F zV=S`Eu&0c>#O?Ykk;j`r2vSE$l=ZT($nxIz(Phr6J%zU?ZfdnmnHhWRuFOUC;j-tk ztUyyR+=0biIn@kv7>f;~mZE2dF^wdWSA|z&Gvm0t*o8?Upch8N@GxvXX60`7^B&=6bHx)$%<-50AnSC>CpJ2!#LWb3_2fzUtZRn1`80JjF0ltYTz*zeY)mIH+7W(z ze`Pfze*uBU!?}4rq$5nz-N?cLbI!VOQyOHS4u$k`(?2O0&O&;fLf^#jcT{2Z5;4}2 zMm9)!HO(gf0A-Pj0~0Cxi`lL3en)?j?Qn@Btf8bXu@`$zo7~^^E764C4?cHXxy*8< zn2rTWi^K_hVV&R{0k}U^-1cUvxP}2oD^09<4`8WYhVN+AUUw?G7fM4&0a*)ruI(KO=zGT`#z|?z&;R=DT=u;HOL8LHexc(^z)%+&2r8w3vhGn8Mb_8*|&?0BC-C zt-KDrQsLD&WaXk*r(?us{vQ^j=l55?rWBc(S6h?^O8lbSxcyUmdxHBFY&hjG{6@G( zFmL|GmC8*`yKi?+d$crix*B@Y!d&;fzS}!cY_+LGF2bRXGVom+$pc$l>IbHZI;yC# z4$jKv*s`8*;5f8wn#kpA;i!OkJC;iL+r)Ek*5i$o`q#%AFAYQWH*3pQj6simou*-H z@2=@%bn(Yh+1Oj%)%dg)4R=KZE_ZnY?W0^Xu2s_%nz({_8}v3!YugmpaIge8{{Y3> zAJJHN4Sla>xSJikm3dm%J+Z#ooYNWPc`3H^hDcpR+Ss=&B4N~D_A0Dy2DQL{?Tok% zyZ(ygZ!+XESe&ndOZk7&$C5)!RZk|iYlBax@T}*CFC4Da#wVneyJjC-E?~DXY2pUH zd{8eyRq@^v$vIM<9KbkwkJUuRm&8HTw}R1{JhEiq;c!(+ zp)pH)Z@TR(j!{svGqSr*S5e6kW`$gHTH%$(#BAi=9?*b`gx#VKWZx*`q%5 z4&I!XpkY|lHPMGP#jgCPg7ZlkmB5XP*}~*=TGG-%Sn^{pF1{?R;pvps?~dLpg{MzY z9CI{`*1gveM~Jh z!;xKnA6m+o@{%qWJ00=6HkS5`ZFLuZ*L%fMudNwu4uLBnQ8;56U7cmDta zy@2<06?L%!1)Rr$^96cv?dH#n=L1n_t!Sd>PE1J)mGDZQfrsc~p=X={Kyfm>kTeUw$u3aU9F zcA?{CEZkXjTyON!V%i48Kp9-&asL1!x*pL$DsbIJ+}Gzl0_FQ13-G$!OAb1kY2B0$ ziH`9!uF21eQIV%OG1}jv#Gj6vxANQ14hc{U&Y~x}mMcjS<89QKRwE2lu{J{1Pfa0V z=3{pQ&=t@@D`Tj1Ef$kel>AGT#jxNpz$BTiZBV#ORXe6pq4Jt#>j&N0&8faQMhA(6HPOxJ<-e4oO9ei$Fa>xb~TI7O8=fIOXD5 za(dZfV@xcol#K{}VbpO8cMV~7824@%?4Vy^{uP;zF|VvP7Hi{c{+gy7RL_{Fk+n4U z2T`j}+$g()R|}(ehFJBwZXGvsGnp^Cj*}-(`vD{=jH#Awz z4j^gNd7{X~fC$~bqWg}WR=q_mQ${Kw7h}ck`A-n2n1fA?#<~@`me}=Y%M6TuR{>+w zVzh9mwaaaKCrJ-`5f-^<;-a2Sn zn=TuAbge^OB@BZrh-usv)!}&OcHri_L%&7K$wqCmYywyxrR~X0{?=}E^-4@uI)(=q z3;ou`JzE?#b?m&Jj}n?93tVl;t=ewVy^R30-8AbwpSPespb?=Ys{Ys`cCNDIM`~pw z!yAf;_=SC0xO-x6&0QYux}zFoY$v<`E`GNinytnMZNU)aJaqP zI&w^ZZh5h#rtZ78k;Fa2uL1H^d(8dhqh~t)jUV)^;xmZq9daC63y+$i6Tk78Imf52 zzq07#dtAcUOGA#i<-eVR-o&a!A7^E2FW%HR27&oobN4?*jfSu^!olz~-DqPYH?v3i zBfYwkr~a3$T$G13UQ?)OA;-&8^;>n0aBLETu2`S)y26Eq(idxMkE+|8i74ruH6^8o z%oF>q4Tkk!&#fIYG{-rk#n6j;aYSy~MTXC^b;8v`nfC5Xh7U{U;qdA!= zk)qB9SjyKp+^{y_^a<3I(p2MeFuKNOT#>LBxj$8vh0gNdddzw5^KvJF z^IJ7DMNf-WM@{0C#oYsa8f-3pYtfD2!zs!5Tn+6XY3e5o{{Z;e2%Dc3|4+oXZXY=B2}3(*|FZdPV3 z+6o*RGU}N?aras^F7g~#oc5FYk5c^XbpFW^+8A}1j;Cg<+appvHovr3UJ}dJRz(@) zPN&gxwcpgdIy?I$H8g+!0A#m6MUPYcWW)#|zQ_Umpq1waqnuEmHlXLSXJrgyUBH2&n!ioY zj;;Ry)C$VHSmF#Z$38&If$sorv|s&8t!u$}VBzjA0g$&-n!|gY6@xRw32-trZbt>1 zHPdq~_Uzx=Y3ij9?;6(=eXVkpMl(@V=J(P98tlN19akHg7kHTW0yq(}yH8j|LN^}Rq)D|J3Aj>>lWLE!p^vBe5De1(Gc(V7)U?ol8T@;)POsKtkcV) zQ#`_BdqLFLlgFxcNX-MX+Rhn(oCg^=|Bc5Tc$T zG)BW;d@8VC-WoU?kJK&RV?9>4a_Tv9xPA@U+KMtBMS%xhH3V~3XlmI|!vyWP zwZ+dp)VploYfPrZL#<$4ZMYR`ik!BfSZGFy%}pcN;Ul7L_}lKR zM+LIT-#=a{X^t(b2*!=v7K7xpg)rr!tAf3+bAyJY1-lhJ!sA`>jHgm_7f`;7h-&CX z7%jbsO%z^9wzR-)CaTO-du%+y)Akd8cFIQ67VZkWO)JAmuniYlQBzoK2ark_NIDOq zy%Z$yx|?fAVt^Nb8)3z0Haw$_iSSa8E=8`5XW>FaPNhN0WAgZH4yRJMin;r&@*7`! zuAj73LKx38vf=R=uO3Gq4yDJU>5OvDu!DvrF&<*nIA#XfeeQ8j#kv=&8u! z_+)X=z0G%%L04tDEMU_!mumCIodjeX4ap51RqoMUl@@5Q3>NfI;gM&lfw$86$@Rb4 zSj9=1t&^D}=DADDt?9^{u(^b8ZaqrVgok0yH~#=_`MzS`vZVHbJ&(Z6`K$B{5>m|- zwR~>3nAh}Oa&pDfc4f{q?_f4_8FBYoQ{C+q4Q~GM>%@Mm8?utO{Yp3=qhxK4yxXGJ zpq0W`!zQ8T-cBF7<9M#Kq?9!;h4MuI0QRK!dg;HS>M&{+QNAhUCRR$v68+0|-}hY1 z(Y3%A9GOjpxO7`xk;d}MBbDPTZp$6PI$3YWdF78JZ$+ry;kq*)Me#3D#l4NWCEFsc>PNkpcU3h3;B zDH4clhN%vzT?cEj3~-IpZjIDY1vn-R(}D+)qUF0&+gNa1^whZ}stvn|J{X5LAJH?m zovs)>CXSWu_P2|d6QoV%SBc|T!|NXCB73{6*`DHV>0QmsHt&ekn&z(KeXOXjsC%KA z?v?sJq5kn4oB2Y(y~CVIB%R7&HZHZcO&7>+K6)!AlG{>dw!8Tup66@&CSOl@fAt7l z9fOJLcU1)K?)?z4ZH`+Wc0$MU+tjHewd^l#ZriCraL*aGme$BaA&u>GUt$h{PQYXt znQ->-{KNM_)Va@uSRHS@mThiC*;zAt!rcah_3}^%|2{Jmn12gT~9P_H^>QT=u|NmNfejV`@FT_RG3`GSxa5B z;yNB{4aJ_#-PO-w+D14JJg4|htAt3x$7)2cVH|-8*d?j)nsyPRpK)>IKXhvIMq^)z1$?4{-OKuGO#N6wtFJqIQHj24OHuBd_iy~C zkx%a&*LQEeX8W!jLY27J!&}`=kc(+D?wW*GWKL;bJaEhU=Ym zi_C`MKiPpB0p_NGtsrS-XhC6cq-jC4zdlLgoL^SzuJ?NDqHv6P`KpSHfq-h=dMi<| zGz$dr5H{al3Pwf&+SX7$HfeIRv6%#M>&0q-a^GK7&32ltl<_)Jd@(e*l~L#YqMg+^ zhjIqu#;~hqoZY?32#t6R_$|>l(h2uCL-6ck&(kMkXw%hm9l_sp3WY8))iAj zq>fgPd}jT}&1%U|S?qKCE@_SDdl9n5jY`>Tn^{il5G7#o05?3=Y_j`3R%p&gy3X@F zQi`xaR~znZ;FOIMYu1S+X6S>b*WcCb-Gw@_6^o zFF?8ks3D#?Sl34*T+%ck-s)@luS3pP(KR@Y5VEKRH5VomWDY5XE8PSDlrEE#Zi6D} z;3)1mC$-8cz$}i_kFOkjE^q6opMXB=7yQvFTq(fTeH3+ZYiLR ztVy>7UGD?TUcoh|P)ma^$y6&iHvSgdp7u)i2J)Q5gCB6Xw-8+oLVpV zkKHD`mw0A(swJ`=GPdT)IisyTIWrEU$Ub`5qrjf zja{cM3fZGG%widXm_}p5{Fe;xI(NcxvnlD#RYN3_8nt<9{o|w4Sq-c_|~m;JP;sM0mbO zPm-%WETz{Cdr0`5r`2x71qqJkp^XD~ z-0FI$Stql-g}M;%3F&lLL$4tCzUg;;=l~7faD0{$1ygv|IJ)9+vIyQ~tnI*9$Aqrodh+HIqUMasKk z>0oYch0i5*k?CtHlo2{7WpK>Xv%y;8ClC#RQbN^X89R*-bXx6oZ@Rf7ZiO!EOBHl0 z2<$%T!L}PIvVeZcHJv_bp&OfK?ZI$8w~$E_X-1v=&5Yaf?tX!KB+dLHNzig$TiV&P z;;@F3W@dM;$iE!zW5lvZ@-{XBa#*X6e)l(E2pxH@qO6Fb@p091wfB3?z9V2dF4(;J zs!+1+mE7FH^}p=5d$8DTPgg!mK@M~B|&S3P)&&Z zrA%)cT&Fu3;SVlowcBU2*KnT;i>W1xa?%_xT=hKO%~K^TmJB48{{U*!rV)thDc$bP8-Vk-@UHb` zXSnK4BZA}f?Uo6>nxOt;^t!9w646JB#L46LStb6*g7V0*Ik4)6-uX_3+a9ZFy|2o^ z$CBus#BJ)m7FP+B)6rYUPG`^6ynMV}y&69Cp>)D@|<_Ee>^P1ZmyJ z8FBgSrq@aVjJD3(mdWw4j&T4zX}V?`T265HUp|17x}i|Uih|ba zZd~EiE;Ap>?b7A=%{D_G1EsUb5rKA{R8Uj*^JQz_e77pxQAlW>=8_y^2Sl)})fj^s z_KemeO)tpxT&LGx(el!Uz}(XBb(bf11G2|=5Ijz`$-f2FS5Ujz!$f43viBRVeYky} znE0$qoB-R->o`{PjGBhOi=5K!h1QfAn#p4u+W!DcD7~0TR={<$i9TDYsaj1z{`Rl)7dzXe zS%7AB6kzzYeTe?#{9(DODIy;frQ~_L_)ey``piR1#>TV&I$cLzd(2uQ)9$+{^BR5D zXKh}Lq#}7^ir33{{$n@ppsuHNRtD`w;&#V~Qc~Pv4mo}4OSIEKKkZt{VN*y=^M?`F zm*}t5?ko0gw8R^o{u05P{ySgL*sIvR8!5?=rp-2#5x|3E>HDk-2AEq2)RCEla~54C zG?CZff$urpPX6iq%Umo!&WhgllbNESc&dZFzlpy0MOvbn!P3GTV1E&winaw-VW^Us z$8)Be@LLXqK0$qQ6K<_LkMok3he^ysOy6mMJ7=_5u8RtBmID*XY|QsuK80jZQ@RSb zvi=u?YlYEJ&}M9IYiw>nKVQ*u$=e9&@Cx%Q1T%r&v)&ina|@@9jdZ`-FjhRzNfS!hVj4yve&Gql#lOBN!naEh0Kb+2H&leC8Tk#X9@gX+C= z7}5~XEn%wi&euZGbv7H&lKD=Ykhz1Uc=j%M<&6oC&=Au zxy0CttRfEW zz~+pXHN%KcrfXd&H@GJD*o64c)=noy9L7@sk2B^t9>F5^p z=eJpccJ~><9tAyPySw|3@ilr^Xa4}K^fS4T;`GLy=R+)dx8%GlIRe@n6)RE#SSQ#BeH+Jq=WlyK({1-eG$;V?U~Nc;Jqc5-p~z zaIkjFdLz6h$p;~rI9w1x;u1w7RAi@=)kZlh7q$4?FgyaTsLB~2VQhBXxa_uEypQ_9 zjy8Jgd?qIXA{pOr%ywBusStRZbO-@y%rm<~!(`~h zY#FkLWjkARfY(M}%}UJ9Ed;T*;_iqdko->DZ=hBsb>;6HaO>LFRhZ_N49>Tj(4>u%f*&Mz<{U1ummIf`JT|CY`Doa3 zompYSQFR?8?xwARRy>A>hZeYP%qGy&RKV6YIbxByt^kk=eO9F=0LdSj^$cJx*3w_> z7oUl@qHgK3`k16)VSZuaV6oA2lD+OPiL5uipbf0LcP?ul7`GUrj8I ziGkhQd{6M@@)~g~l;fvsHpBLBWv6tqi#~63aSi9{s?spNyaw(81Jo@V(D|hATr55Y z52v6@2&Ml3&eVI2FLzE~bv9_&D2$aGz^bM#lkSyhV5D}ULg2T`T?Cr~)U z?RC8yfAQ#zhKWT<#EJD2wP@jih?g@hBDd_qBY9L{JzCjC?`kGr40 zklm5A<^JcN*dMCZWiwwNcG4LfKp7l%1BdJLShaORa_42q3jwe?@%|Ns;Vl90Dh$ok zX>X_~z5U)VO4pswcJG$Iaq?5(@Cs%^UNmSkKK!>?aLD?qxaWD-z7kv4U(s3HH>j{F ztzO86;#W85LXQKgF}k7$4+O=`w@`2QS{1|D@j^hhN0|r8{{XVa!^k*>QtJ}uJMLBP z9aY()6!lU})0WmZ{G{<)F-m31@O4<&%fmw&&;91-{vdwkTf-!CSjM>S-3jIvZ(DC3 z8&XM>ML*>>carUJx^ntv80_F4UO#WD;-2O@idL4~78~?C-FgpX8Q)>>>*H$;{O@JT zy*S+`q+z6~b3PZ@!b)BhVD8K0aO4N@ywkOF{w9}c0NC-{b%#xG=VOn%jq^y*=e@?mkXe;XE|8Y{ z#BIrJQpb8`mjXa@LFP$DzYKF~V(!MTew_ddSlv0%J3 z#&lM(XhD}dhP+2BDzqm(4gA}0RlV)X%`9RcjRdW4zfM?vGLPj^4QQ#2@_)56azCgk z?yYQiy<8;t9^%b;SP!C(jESEu6od!Ia>2}WTIr725{FTj)4UfLTwDJDCg1**{3f&Sjo~gjv z7qCSuCSzoc!Od{EAnq5*5z0XX73$y8{{ZR} z>Ew?H#AGgk?h1+rOx%#ZfRo}MR2oP* z50OIWD0dbc5zHVr%ysz2y%ILj+{KC(uy6<6H}puK7fu7B0@81;=;?paG#{!qHn}z* zBnXlYt{`$6BzwC!*pe(jweFKkZ?*Z@Bbodf2MtOD_milO+5@Rbz4stx8u_9_j$6Dp zOIQUSjsor^+n*E!Ywv1$1tezA@i)W9+oo7~T^887*S4FbBEuU`qr1Y>%!B)_7$cF| zG0c!{q=)UY%ZdW<1Hdij4TxGa7;J^$w^7X8G=A%jT8`Lmx(p_Y@-=~tFDJuhw13jw zd=X@AJPj__;rQLeta?W|vHLwRbBXt)BG>2u*embFsj%2f3)*EO*VvyW<;vT5da;s3 zbyUWgj*#0oGqXoT%0@>?MI(SFY1{{kdn&%yHB^F0?m^X!cEhc@bxHL?c^YHXZU;^m z{{T3ttu?)_JQp%5RuVw7oO!tMK1y@l(X{3vw;P{?bMsYwsgzL}AFR&OHhs#<#A_cr z%X1TNsp66ru2tePRJ6wp+ZFB`%<(6gP3~CunH#;(d{^Uc9;H=;wl)V!>pyn1Zmd(v zXM0#QxUjavnObFPN!=YR?j+t~BW<~ER&6Lf;dnY+F}*Lkmk_DW=2J&vV+4z9;waKY z<~|qz*g97e)M@ukaGzJJ96hR8Z=q&gMxN+cCCz1HUen@aBL4s(`>m`>X0{p2je|-1 zEF3gGd0z8tJT-t@&-hxxakS3!rL1E-Qb6!^j2jL0AS-xoA?>uWI>C-)rMGF{MW2dQ zGI!KvBGJ0To`Fe(z-cLIZ>MD6zA={n043r0PxZ$Wq-!Iu!>K9-wK11Q?jB=;=;(j1I4ss} zULRcn+U?5l2h1yc88wgcF#Ww2s+-#>i!>59#Tn$`s6RU^baQ)EG;&(S%><4kn&Be; zuJ%@{*(={0j|pOPY_@RD-F9KjF|!+?H*w2(a_{D`{drG~{{U_`>#4Q6AT<^z)n`T8 zOe&_vEJ}&s9JlYYxNSjDd6gpq~n=gEW=2fwr?jmAd^@xL=J=Bc1Z3(|M&rnDmPWYI7O?deebnI)kboj1#V}ejX_=vW= zx-5JSvC=-F`(^>ac@@+8>~OwGZv|C5W5#DP6K?&(UPW4|kZL8*}c=!^@5OEKG9>{{T*u zifcNaOTB>Z2k@e0RM>4%A=zZxQ{iyxw<+r>ahhK3?E*$?lZf)@v#X8Y#oWqk>zSdO zy9Ra=9k-I`WpkvbdycuX+!3asXMd)~YT||>8q)9{)4*SX&=nB6mSz$Ge1|Wl$LN_& z*`&tmYO*V(5xcu2HA%XK5C|7b331fdNIMn zEZg<9B5GJ>h>~*I?Y{k1B(J@+xW1fGf@iUzgn$SmRT1%6lH1>s-Z<-qGRtBQ_Fq(N zN5DR5go^=kbvp#O`>rcYo*I83NQ)nEdLjP+(aCsisBlya*5c;*qkCA})F224!u>j< zt97tGNDkk+nhBi?gPKSqUDP0gd^_?->Cp$!KhqA`{{Z<08SHgW{{YOUXaxRt^Ig7e zSIj+{$7(PPPAb}Y3rkOkerE9t=`1S{hZe)&tfzQ%aS6XxQT% zcG|;bftcFo-f7hkGXi#Y={dlV`)}cJPzgL>JZ>~0EZ1<_1Uq`4v5IU#nD#@ilak^s*Oh`p?zrSGXBMW8mikT`%gXebkd zhU7GChJ^dhaXgVA9c)eYxkLvG9|+5Q6a$W0KUL;Na!qHc#Z6uGI^a3 z5~u_VT$6iqMW;@F6b_)-r{!dut3U^ZzN!sMX+RAoHwz6MU9!FYstL;1W;WNLSdhxh z`D6qE#O>;|>ENR+CG#}jUlxbZt{LL*hF9Wqi_~1j5qn$5!(?fk3;V~gy~98o#X32u zVHXkt2OHf)>|)uwmb7wl(!-kN{{SPe{jCQ*=QA8bOY9tPw?z4I2q_tOeece9{uT_C zZZ-{hCC5{JQDdD|D_GXqqehR_PfyzW{+hJa4tIH7Q+qIYZ_j{KMOcl+@{yeDl<-Fr zu4FBamMb_g-1R)l%x-C);w0;H(Q0G$E=_3XG7j~_!cDAxBgtHCPq9MjoJo7eR)NC% ztoA*vV_yFNb+!y==1Q_)z}aMY8Vd{VPenDlZ&9a4(8d|1Buf7PbX#lj`>WIw2_Gd( z+5nN+Tc^Ug*lK01eS@5Hp6na#%&m!PgiVSzSTZ(db-D8UE0#YDa{8l;Ryt-$8(c%4 zbqAT(?e|jQc7?SQ*a+ciAoH;O?6TS&Sv%hAhJ%!IB=2RTiApG_A){dh^$!`y{G<6= zvyWCK=h;QEzIo=3*}o769y?H=>(IT z48y_+=l%tZ@5b<-Rj~?zOH6D?e6Fw~kXcj@j(=$e?E)v8asZFnELNqHI?Oj~JPqq@ z&#Gn~?7jH4^E11-8{htyY;o2uIc2<+Ui{831Z%aC3^q}|CT0TG(CkqRbA8!e>bp54 zYn;J*@lG$lhkzVZ2R`}Q?>!RyR_kG}R7Awu_6g09i;zh5P!~!;@NdHD7sv(sz6dNA zz3wEr#@xzdvmFW3br_k%kHg<&=E&y)8yL`Pf!8MD_ps41!oU;>lR_Ff4ymKPbBlBY zA+@?j6L*udgH_BjK^1ISNVD8pLFHwh@>@;-l>5T=7P(D|K^040)9yPfe&nusW4BCl z)oA0AG*$POIkJtF0W;h$5X$Bp%sXG2xnEM*V`IgyZFKppvQ6V7Lwl=){CP9KlS*38 zZ-|MDbC05<6-@Lq2gLHpS;VN_r+P-)XoJYi?eu81_$%uf_P3VJUMOoI6|pcfMvF)W z!0=QbUio3l?+e;N1S-rfiIx|$v>_YYHP6M#CNdW*O*hOl7P{QgYej|I@o-HoZoVLP z;GEX#2VM(IoCUIw2;C;U_(jJa2s9o}*zrolTT5BI)c})ri;j$s>{YHm0b4`Z!uSFis7MY~8c&4N@sU;W5#cjgz5H+%D1crGg@ z4T@7&M&cb4!PAK1h{|v|L)vv4pzB{SHf!`f3Pt*78+=Kp!fm@9^eCq|8<2hBuc{X= zwq7RVp+VWLaCK+YA^|qGnYA8j1DUqk%y-cNq3+$Sz277*4awN=(4~}-ko(Qp^HeeV z0p_G@uc~1=hGVBhp*fmb2ST8cw{X83YsD`I#Bb_}0l*g#uMU2QT(oH0dFZGhhTP>m z4uK~VW8osliQt9FI=BJQA($J2;!U;Jk_MKWl592Pj6h`EoplI5Z3f{cKJK?NIH(D@ zFCY=EwoP-1Ce9%9JE;zuf)9(51l*m7&14K?+`*%cZe-{cwmLr!X$U8G5usGe3EIS= zme;t{o@+N2rdn%7OvgIxAPvjPvL=p>H@YxM%fAw`AgOdiD|Ej-lckin0^)9Ucj&oX z9cFwMStgW{HF3EoWv5R|954y$2xK?nHF+EYuF9CHV$5UC^Rrxj_SsXZs+z7tB+o7* z!UqP9Kyp{s)b^1*Mj%{&4Vg{!Cwp-6Rl{Vek~Wg(7T)BZdZfVI*LL^Yn!NDL*HrayKwbLI_E80e4UoNEo z0B+%KGP@>r8fWPGm7NprIkxhaktyQ`jZO+-$6oeMWUEBdP~ki#4d zvCQX(n{~Jfvj^b<;B9}JGdR8Zw9t*0eLV7mi7o`4Y*LJl@xxC z@KppGM(qucUnJs9nPYx>c_s_@M#o->EsT>58WY2P^h_X#i33}pdx*3{Q?Eouk9&wDkO0** z-t0QxTY`?xlIHj1Pz?V7fErDO?|xdO?U+lgnB1o77zNYGXmHiW$P1=D+uB`w9W+fK znH%od3+u85#fQX2^|iN2#^jTIfW44355p$IS8HON`x7B9aMTS#P6Eyuc!V9Hexu$3 z$Q(z#VS5p+&@MN3f!556fV;G|qhgUd`^0#V3$@1Or&n@7HJ1beH9Qdo&9{thPN5L@ zX{gW?4e)`XKI$a1T}jzEmmh_`>I6H)16nl-hMN-WH0%_D`hyy0h?D2QCk)m;Bmu>M z@dY3Yz1M^ZH`2uq2_7bpd7ybN2FA&n6P3d&xFv2N+$EcX{%9Q1;BuZDbwm$C#3q54 zL#uccbt7H(1rXps8fkRx;x|4?pkH2k6rMn6K^>d#ing9bS zH)-OEb8ZOy&EKd>;iB9xe?$$#h&K)!bxPvG!tKAwK=5}YZ|{ESI_+rUMxgTuUoEou zh}iQSlbABM!fziQiiSGY%+qk~eN=E9vhQG2nR4c5!gU=wC?0ai2pT9YH0AeOeRuv+ zc;M%A9FS=*2U`siHom%_nLvh40FmW%i;H$`fa;E?OIx8RW!#qmalk4Y;CbB(i%qFrj~#& znLgS9Yv@qm?GA$XC;@2}xwz6q)iu}D+lnBP zFPQDibT!h&5N>rk4M5R5FX8~}=q`b94I{A<{Y1rSNMB>m~;2bsQ!$mY~=S)luHo4PMoN6TxYi{he)C0do0sjDLx`S>OPatO= zNA8+8MrWBGT<Z|ak*=e64mr6!Ob^PG$E=70m>BScGlOw)-? z-YaJZU6cMcfs}55wAs(_jY?x2;TwR^bVawhu^^IduS8r3fxF)JwYc2?C&a;G;d_u% zT;IYPYF*2sl0Zw1+APt12Q<@sjN5%R)d2XLU@IbCjhGD-Xi%Gv5ylfMN&Qq1Q2KVNg zG%ObQiO~Sd{mulxifj1XfJ^nbQZ#?IKQoEF(?y&csJ3EIB5!>fZvE2M{_n(4Yi`hO zp>g7fwwEMt#Q<)=9J>;gg}v6D61lLk;l&Y{4-g$Js98mg#jkCUw*Di!K$0}Iv`0(o zMe`s{EZ3d+r;q?8yP&`weOh(ql#DI00Jo?>(n%uiw!4CmzXPXKwKq2o`V`x?_PGRb zC<(FL5q=>pK-SI)c!vvf5Gv<=3imn6Z@VxDz6ZcsFndyiDT=G&5g z6UTyW#qGe25HJkBA*ebO(`K6!;5&3i)^3Dff>s1=x4bzZKpnSm*)+H`n+pS>L_W2A9=TfzE7&qjR#ilw}#;rM3!&xLe+KytYZX zMy+kmyb%OkWNpthyk_?;Y6#pPZ4Obmw*?G`UMgEOTI9H$3A&0wpf^xiW3W$r;X~72Z*4o=pfV7BQ#T@lGTcv;(#LkTt{x$*I&0bwK7KHX`GEX zpasnq-_Y3*Dk(^InD8looqHhnp)R104&z0M&Jy75J0*YX>+_}_(0hx z$jsY3Xx+*H+gXEAsX8JK-bZwRzDp2vDK|BSt;;^G45s10`^m&Ppk#H&B9Ok;0_N$2i|R<~fjPjR6G-wx z;o4jfd7^K?=25xLHoH$W4>xYd(JPl|LX22y96(NMov?5y9yA1vLU6GC6IyT3@Ep^L fu?E-H$O#~5P3{vx*ysWzTHtWk$qNkK5P$#Krf-IP literal 0 HcmV?d00001 diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fbdf4ea2f3718fe5e72169e3163374c238f52d1c GIT binary patch literal 28494 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 literal 0 HcmV?d00001 diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5c7e8da433d7f7da1fecbf8e26cd286d3f6f5bd2 GIT binary patch literal 38861 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�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 $Yo3Lm+(4``${+1O*f~ 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 hAaXsFlb@i> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 496 >> +stream +BT /F1 12 Tf 50 780 Td 16 TL (Atelie Nord - Fabric catalogue 2026) Tj T* () Tj T* (Curtain fabrics width 280-300 cm) Tj T* (Blackout Nord - 100% dimout - 1490 RUB/m - 14 colours) Tj T* (Blended linen - 890 RUB/m - 9 colours) Tj T* (Velvet Verona - 2190 RUB/m - 6 colours) Tj T* (Canvas - 690 RUB/m - 12 colours) Tj T* () Tj T* (Sheer fabrics) Tj T* (Voile - 390 RUB/m) Tj T* (Greek mesh - 520 RUB/m) Tj T* (Linen sheer - 740 RUB/m) Tj T* () Tj T* (Samples: up to 5 pieces, free by post.) Tj T* ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000788 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +858 +%%EOF diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt b/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt new file mode 100644 index 0000000..355c99e --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt @@ -0,0 +1,14 @@ +Каталог тканей 2026 — Ателье Норд + +Портьерные ткани (ширина 280–300 см) +Блэкаут «Норд» — 100% затемнение — 1 490 ₽/м — 14 цветов +Лён смесовый — 890 ₽/м — 9 цветов +Бархат «Верона» — 2 190 ₽/м — 6 цветов +Канвас — 690 ₽/м — 12 цветов + +Тюль +Вуаль — 390 ₽/м +Сетка «Грек» — 520 ₽/м +Лён-тюль — 740 ₽/м + +Образцы: до 5 штук почтой бесплатно. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md b/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md new file mode 100644 index 0000000..2e571b3 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md @@ -0,0 +1,32 @@ +# Расчёт стоимости пошива штор + +## Формула + +**Стоимость = ткань + пошив + фурнитура** + +| Составляющая | Как считаем | +| --- | --- | +| Ткань | метраж × цена за метр (см. каталог тканей) | +| Пошив портьер | 450 ₽ за погонный метр готовой ширины | +| Пошив тюля | 350 ₽ за погонный метр готовой ширины | +| Люверсы | 90 ₽ за штуку (шаг 18 см) | +| Шторная лента | 120 ₽ за метр | + +## Коэффициент сборки + +- Плотные портьеры — 1.5 +- Лён, канвас — 1.8 +- Тюль, вуаль — 2.0–2.5 + +Ширина ткани = ширина карниза × коэффициент. Высота ткани = высота изделия + 15 см на подгиб. + +## Пример + +Карниз 3 м, высота 2.6 м, блэкаут «Норд» 1 490 ₽/м, две полотна. + +- Ткань: 3 × 1.5 = 4.5 м × 1 490 = 6 705 ₽ +- Пошив: 4.5 × 450 = 2 025 ₽ +- Лента: 4.5 × 120 = 540 ₽ +- **Итого: 9 270 ₽** + +Минимальный заказ — 3 000 ₽. Замер по Санкт-Петербургу бесплатно при заказе от 15 000 ₽. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md b/apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md deleted file mode 100644 index 5630993..0000000 --- a/apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md +++ /dev/null @@ -1,30 +0,0 @@ -# Репер — спецификация телематики - -## Поддерживаемые протоколы - -| Протокол | Назначение | -|---|---| -| Teltonika | Бортовые контроллеры серии FMC/FMB | -| Wialon IPS | Совместимые трекеры сторонних производителей | -| Galileosky | Контроллеры Galileosky v2/v3 | - -## Совместимое оборудование - -- **Бортовые контроллеры:** Teltonika FMB130/FMB140, Galileosky Base/Smart. -- **Датчики уровня топлива (ДУТ):** Омником, Эскорт, LLS. -- **Датчики моточасов:** входы дискретных и аналоговых датчиков. - -## Частота опроса - -- Положение: раз в 30 секунд при движении, раз в 5 минут на стоянке. -- Топливо: непрерывно, усреднение за минуту. - -## Атрибутика единицы техники - -- Госномер, модель, тип (грузовик / спецтехника / прицеп). -- Привязка к бортовому контроллеру по идентификатору (IMEI). - -## Ограничения - -- Минимальный интервал данных — 10 секунд. -- Один бортовой контроллер = одна единица техники в подписке. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md b/apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md deleted file mode 100644 index e59aeec..0000000 --- a/apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md +++ /dev/null @@ -1,26 +0,0 @@ -# Вектор — руководство по развёртыванию коробочной лицензии - -## Системные требования - -- **ОС:** Linux (Ubuntu 22.04 LTS / РЕД ОС 7.3) -- **Процессор:** 4 ядра -- **ОЗУ:** 8 ГБ -- **Диск:** 80 ГБ SSD -- **Сеть:** доступ к контроллерам оборудования по OPC UA (4840) и Modbus TCP (502) - -## Порядок установки - -1. Разверните дистрибутив из архива `vektor-server-.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». - -## Проверка - -После подключения зайдите в «Живые данные» — метрики должны обновляться в течение секунды. -Если данных нет, проверьте сетевую связность и брандмауэр. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md b/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md new file mode 100644 index 0000000..3cc64a4 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md @@ -0,0 +1,15 @@ +# Голосовые сообщения демо + +Сюда кладутся аудиофайлы, на которые ссылается `data/conversations.json` +(`messages[].voice.file`). Речь должна быть человеческой и совпадать с текстом +`transcript` соответствующего сообщения — расшифровка показывается рядом с плеером. + +| Файл | Кто говорит | Длительность | Текст | +| --- | --- | --- | --- | +| `maria-hurry.ogg` | клиентка, Мария | ~14 с | «Анна, спасибо! Если получится отправить завтра, будет идеально — в субботу приезжают гости, хочу успеть повесить.» | +| `olga-color.ogg` | клиентка, Ольга | ~9 с | любой текст: сообщение показывается нерасшифрованным (кнопка «Расшифровать») | +| `dmitry-samples.ogg` | сотрудник, Дмитрий | ~18 с | «Дарья, добрый день, это Дмитрий из ателье. Отправлю вам три образца блэкаута: графит, дымчатый и тёмно-серый. Напишите адрес с индексом, уйдут завтра почтой.» | + +Формат — OGG/Opus (как в Telegram и MAX), моно, 16–48 кГц. Лицензия — совместимая +с открытой публикацией (CC0/CC BY или собственная запись). Если файла нет, сид +пропускает сообщение с предупреждением в логе — остальные данные ставятся. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/operations.json b/apps/backend/hub_platform/identity/demo_seed/data/operations.json index 8d28898..098cc07 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/operations.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/operations.json @@ -1,59 +1,25 @@ { "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 - } + {"key": "call_maria", "conversation": "maria_first", "initiatedBy": "anna", "contactIdentity": "maria_tg", "deliveryConnection": "tg", "kind": "AUDIO", "status": "ENDED", "endedBy": "STAFF", "daysAgo": 33, "durationSeconds": 262, "metrics": {"connectionType": "RELAY", "localCandidateType": "relay", "remoteCandidateType": "relay", "roundTripMs": 64}}, + {"key": "call_darya", "conversation": "darya_measure", "initiatedBy": "dmitry", "contactIdentity": "darya_tg", "deliveryConnection": "tg", "kind": "VIDEO", "status": "ENDED", "endedBy": "CUSTOMER", "minutesAgo": 1395, "durationSeconds": 540, "metrics": {"connectionType": "DIRECT", "localCandidateType": "srflx", "remoteCandidateType": "srflx", "roundTripMs": 31}}, + {"key": "call_olga_missed", "conversation": "olga_archived", "initiatedBy": "anna", "contactIdentity": "olga_max", "deliveryConnection": "max", "kind": "AUDIO", "status": "MISSED", "daysAgo": 12}, + {"key": "call_viktor_declined", "conversation": "viktor_wholesale", "initiatedBy": "svetlana", "contactIdentity": "viktor_tg", "deliveryConnection": "tg", "kind": "AUDIO", "status": "DECLINED", "endedBy": "CUSTOMER", "minutesAgo": 4185}, + {"key": "call_sergey_expired", "conversation": "portal_sergey", "initiatedBy": "artem", "contactIdentity": "sergey_web", "deliveryConnection": "web", "kind": "AUDIO", "status": "EXPIRED", "daysAgo": 6, "invite": {"deliveryStatus": "DELIVERED", "expiredMinutesAfter": 2}} ], "notifications": [ - { - "type": "DIALOG_WAITING", - "level": "WARNING", - "title": "Диалог ожидает оператора", - "body": "Сергей Орлов ждёт ответа в канале продаж Вектора.", - "targetRoute": "chat", - "targetId": "vektor-queue", - "audience": "OPERATORS", - "sourceId": "vektor-queue", - "readBy": "sales_lead" - }, - { - "type": "DIALOG_NEW_MESSAGE", - "level": "INFO", - "title": "Новое сообщение в диалоге", - "body": "Наталья Кузнецова написала в продажи Вектора.", - "targetRoute": "chat", - "targetId": "vektor-lead-active", - "audience": "OPERATORS", - "sourceId": "vektor-lead-active" - }, - { - "type": "INTEGRATION_ERROR", - "level": "CRITICAL", - "title": "Проблема с интеграцией", - "body": "Подключение Репер MAX не проверено. Данные могут не доходить.", - "audience": "OWNER", - "sourceId": "reper-sales-max" - } + {"type": "DIALOG_WAITING", "level": "WARNING", "title": "Ирина Левина ждёт оператора", "body": "Заказ 4390: клиент попросил человека. Ждёт 1 ч 50 мин.", "targetRoute": "chat", "targetConversation": "irina_status", "audience": "ALL", "minutesAgo": 110}, + {"type": "DIALOG_WAITING", "level": "WARNING", "title": "Ольга Мельник ждёт оператора", "body": "Возврат: цвет не совпал с сайтом.", "targetRoute": "chat", "targetConversation": "olga_return", "audience": "ALL", "minutesAgo": 21}, + {"type": "DIALOG_WAITING", "level": "WARNING", "title": "Полина Ракитина ждёт оператора", "body": "Почта: не приходит письмо для сброса пароля.", "targetRoute": "chat", "targetConversation": "polina_password", "audience": "ALL", "minutesAgo": 200, "readBy": ["anna", "artem"]}, + {"type": "DIALOG_NEW_MESSAGE", "level": "INFO", "title": "Новое сообщение от Марии Соколовой", "body": "«Спасибо, понял»", "targetRoute": "chat", "targetConversation": "maria_delivery", "audience": "USER", "recipientUser": "anna", "minutesAgo": 4}, + {"type": "INTEGRATION_ERROR", "level": "CRITICAL", "title": "Telegram · бот доставки: ошибка подключения", "body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.", "targetRoute": "settings", "audience": "OWNER", "minutesAgo": 4200}, + {"type": "RELEASE_PUBLISHED", "level": "SUCCESS", "title": "Обновление Chatbolls: голосовые сообщения", "body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.", "targetRoute": "chat", "audience": "ALL", "daysAgo": 2, "readBy": ["anna", "dmitry", "svetlana"]} + ], + "messengerBindings": [ + {"user": "anna", "connection": "tg", "externalChatId": "482900010", "pushTypes": ["DIALOG_WAITING", "DIALOG_NEW_MESSAGE", "INTEGRATION_ERROR"]}, + {"user": "svetlana", "connection": "tg", "externalChatId": "482900011", "pushTypes": ["DIALOG_WAITING"]} + ], + "messengerBindingCodes": [ + {"user": "dmitry", "connection": "tg", "code": "DEMO-7K2P", "expiresInMinutes": 25} ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/organization.json b/apps/backend/hub_platform/identity/demo_seed/data/organization.json index dd590c8..e9f19b8 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/organization.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/organization.json @@ -1,82 +1,85 @@ { "schemaVersion": 1, - "organization": { - "slug": "severnaya-verf", - "name": "Северная Верфь", - "timezone": "Europe/Moscow", - "currency": "RUB" - }, + "_comment": "Демо «Ателье Норд» (дизайн-базлайн v2). Ставится в организацию установщика: организация и её владелец здесь не создаются. Пароль всех демо-сотрудников один — см. demoPassword.", + "demoPassword": "Chatbolls-Demo-2026", + "showcaseAccounts": ["anna", "dmitry", "artem"], + "groups": [ + {"key": "consultants", "name": "Консультанты"}, + {"key": "support", "name": "Поддержка"}, + {"key": "operators", "name": "Операторы"} + ], "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": "Елена Соколова", + "key": "anna", + "email": "a.kim@atelie-nord.ru", + "fullName": "Анна Ким", "role": "ADMIN", - "positionTitle": "Руководитель клиентского сервиса", - "phone": "+7 812 240 19 01", - "isStaff": true + "positionTitle": "Администратор", + "phone": "+7 921 300-41-17", + "groups": ["consultants", "support"], + "uiTheme": "LIGHT", + "uiAccent": "#1677ff" }, { - "key": "sales_lead", - "email": "d.morozov@severnayaverf.ru", - "password": "Demo-Sales-2026", - "fullName": "Дмитрий Морозов", + "key": "dmitry", + "email": "d.orlov@atelie-nord.ru", + "fullName": "Дмитрий Орлов", "role": "EMPLOYEE", - "positionTitle": "Старший менеджер по продажам", - "phone": "+7 921 330 11 45", - "group": "sales" + "positionTitle": "Консультант", + "phone": "+7 921 300-41-18", + "groups": ["consultants"], + "uiTheme": "DARK", + "uiAccent": "#7c3aed" }, { - "key": "sales_junior", - "email": "k.belaya@severnayaverf.ru", - "password": "Demo-Sales-2026", - "fullName": "Кристина Белая", + "key": "artem", + "email": "a.gusev@atelie-nord.ru", + "fullName": "Артём Гусев", "role": "EMPLOYEE", - "positionTitle": "Менеджер по продажам", - "phone": "+7 921 778 02 19", - "group": "sales" + "positionTitle": "Специалист поддержки", + "phone": "+7 921 300-41-19", + "groups": ["support"], + "uiTheme": "SYSTEM", + "uiAccent": "#0f9d58" }, { - "key": "support_lead", - "email": "a.novikov@severnayaverf.ru", - "password": "Demo-Support-2026", - "fullName": "Андрей Новиков", + "key": "svetlana", + "email": "s.petrova@atelie-nord.ru", + "fullName": "Светлана Петрова", "role": "EMPLOYEE", - "positionTitle": "Старший инженер поддержки", - "phone": "+7 812 240 19 50", - "group": "support" + "positionTitle": "Старший консультант", + "phone": "+7 921 300-41-20", + "groups": ["consultants", "support", "operators"], + "uiTheme": "LIGHT", + "uiAccent": "#d6336c" }, { - "key": "support_engineer", - "email": "m.zhukova@severnayaverf.ru", - "password": "Demo-Support-2026", - "fullName": "Марина Жукова", + "key": "igor", + "email": "i.saveliev@atelie-nord.ru", + "fullName": "Игорь Савельев", "role": "EMPLOYEE", - "positionTitle": "Инженер технической поддержки", - "phone": "+7 812 240 19 51", - "group": "support" + "positionTitle": "Оператор", + "phone": "", + "groups": [], + "totpEnabled": true, + "uiTheme": "SYSTEM", + "uiAccent": "#1677ff" + }, + { + "key": "natalia", + "email": "n.frolova@atelie-nord.ru", + "fullName": "Наталья Фролова", + "role": "EMPLOYEE", + "positionTitle": "Консультант (уволена)", + "phone": "", + "groups": ["consultants"], + "blocked": true, + "uiTheme": "SYSTEM", + "uiAccent": "#1677ff" } ], - "groups": [ - { - "key": "sales", - "name": "Операторы" - }, - { - "key": "support", - "name": "Поддержка" - } + "invitations": [ + {"email": "v.kotova@atelie-nord.ru", "role": "EMPLOYEE", "createdBy": "anna", "expiresInDays": 6}, + {"email": "old.invite@atelie-nord.ru", "role": "EMPLOYEE", "createdBy": "anna", "expiresInDays": -2} ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/support.json b/apps/backend/hub_platform/identity/demo_seed/data/support.json index d7e2b04..d7dc78e 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/support.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/support.json @@ -1,105 +1,46 @@ { "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" } + {"key": "site_contract", "code": "SITE-2026", "product": "atelie-nord-site", "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 + "key": "sergey_account", + "contract": "site_contract", + "subjectKey": "user:4402", + "accountKey": "acc-4402", + "displayName": "Сергей Крылов", + "displayEmail": "s.krylov@example.ru", + "payload": {"orders": [{"number": "4402", "status": "В производстве", "total": 12480}], "city": "Казань"}, + "operatorContext": {"Клиент с": "апрель 2026", "Заказов": "2", "Последний заказ": "4402 — комплект штор, 12 480 ₽"}, + "aiContext": {"orders": "4402 in production"}, + "searchText": "Сергей Крылов s.krylov@example.ru 4402 Казань", + "issuedDaysAgo": 6, + "expiresInDays": 24 } ], "portal": { - "slug": "reper", - "name": "Репер — центр помощи", + "slug": "atelie-nord", + "name": "Помощь · Ателье Норд", "locale": "ru", "status": "PUBLISHED", - "widgetChannel": "reper-help-widget", - "products": ["reper"], + "widgetConnection": "web", + "accountWidgetConnection": "web_account", + "products": ["atelie-nord-site"], "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 } + {"slug": "zakaz", "name": "Заказ и оплата", "description": "Как оформить заказ, оплата, статусы", "sortOrder": 1}, + {"slug": "dostavka", "name": "Доставка", "description": "Сроки, курьер, СДЭК", "sortOrder": 2}, + {"slug": "vozvrat", "name": "Возврат и обмен", "description": "Условия возврата изделий", "sortOrder": 3}, + {"slug": "uhod", "name": "Уход за изделиями", "description": "Стирка и глажка по типам тканей", "sortOrder": 4} ], "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 - } + {"slug": "kak-snyat-merki", "category": "zakaz", "title": "Как снять мерки для штор", "summary": "Карниз, высота, припуски — за пять минут", "content": "## Длина карниза\n\nИзмерьте карниз от края до края — не окно. Шторы должны закрывать окно с запасом 15–20 см с каждой стороны.\n\n## Высота\n\nОт крючка карниза (или верха трубы) до желаемой длины: до подоконника минус 1 см, до пола минус 1–2 см, «в пол» с напуском плюс 3–5 см.\n\n## Люверсы\n\nДля штор на люверсах прибавьте 3 см сверху.\n\nСфотографируйте окно целиком и пришлите консультанту — проверим расчёт бесплатно.", "status": "PUBLISHED", "helpfulVotes": 14, "unhelpfulVotes": 1, "publishedDaysAgo": 27}, + {"slug": "sroki-dostavki", "category": "dostavka", "title": "Сроки пошива и доставки", "summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России", "content": "Пошив занимает **5–7 рабочих дней** после согласования расчёта и оплаты.\n\n### По Санкт-Петербургу\n\nКурьер — 400 ₽, бесплатно при заказе от 10 000 ₽. Интервалы 10:00–13:00, 13:00–17:00, 17:00–20:00. Курьер звонит за час.\n\n### По России\n\nСДЭК до пункта выдачи или до двери, 3–5 рабочих дней. Трек-номер приходит на почту в день отправки.", "status": "PUBLISHED", "helpfulVotes": 22, "unhelpfulVotes": 3, "publishedDaysAgo": 26, "secondRevision": {"summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России. Перенос интервала — бесплатно", "contentAppend": "\n\n### Перенос доставки\n\nИнтервал можно перенести бесплатно до 18:00 накануне — напишите в чат или на info@atelie-nord.ru.", "daysAgo": 9}}, + {"slug": "vozvrat-i-obmen", "category": "vozvrat", "title": "Возврат и обмен изделий", "summary": "Готовые изделия — 14 дней; индивидуальный пошив — переделка при браке", "content": "Готовые изделия из каталога возвращаются в течение **14 дней**, если не были в использовании.\n\nИзделия по индивидуальным размерам возврату не подлежат (ст. 26.1 ЗоЗПП), кроме брака и несоответствия заказу: неверный цвет или размер, дефекты пошива. В этом случае — бесплатная переделка или обмен, срок 5–7 рабочих дней.\n\nДля заявки пришлите номер заказа и фото изделия в чат.", "status": "PUBLISHED", "helpfulVotes": 9, "unhelpfulVotes": 4, "publishedDaysAgo": 25}, + {"slug": "lichnyj-kabinet", "category": "zakaz", "title": "Личный кабинет: вход и восстановление пароля", "summary": "Письмо для сброса приходит в течение 5 минут", "content": "Вход — по e-mail. Письмо для сброса пароля приходит с адреса no-reply@atelie-nord.ru в течение 5 минут. Если письма нет — проверьте «Спам». Повторный запрос доступен через 10 минут.\n\nСтатусы заказа: «Ожидает оплаты» → «В производстве» → «Готов» → «Передан в доставку» → «Доставлен».", "status": "PUBLISHED", "helpfulVotes": 6, "unhelpfulVotes": 0, "publishedDaysAgo": 20}, + {"slug": "uhod-za-shtorami", "category": "uhod", "title": "Как ухаживать за шторами", "summary": "Стирка и глажка по типам тканей", "content": "**Блэкаут** — деликатная стирка 30°, без отжима, глажка с изнанки на минимуме.\n\n**Лён** — стирка 40°; усадка до 3% заложена в расчёт.\n\n**Бархат** — только сухая чистка.\n\n**Тюль** — стирка 30° в мешке, сушить прямо на карнизе.", "status": "PUBLISHED", "helpfulVotes": 11, "unhelpfulVotes": 0, "publishedDaysAgo": 15}, + {"slug": "oplata-chastyami", "category": "zakaz", "title": "Оплата частями", "summary": "Черновик: условия 50/50 для заказов от 30 000 ₽", "content": "Для заказов от 30 000 ₽ возможна оплата 50% при заказе и 50% перед отправкой. Черновик — согласовать с бухгалтерией.", "status": "DRAFT", "helpfulVotes": 0, "unhelpfulVotes": 0}, + {"slug": "akciya-leto-2026", "category": "zakaz", "title": "Летняя акция 2026", "summary": "Скидка 10% на тюль до 31 августа", "content": "Акция завершена.", "status": "ARCHIVED", "helpfulVotes": 3, "unhelpfulVotes": 0, "publishedDaysAgo": 70} ] } } diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py b/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py index d532bb3..9ab0690 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py @@ -1,154 +1,283 @@ -"""Каналы, интеграции, AI-агенты, база знаний (со вложениями), учёт LLM.""" +"""Агенты (карточки Channel+AIAgent), подключения, LLM-провайдер, знания, учёт LLM.""" from __future__ import annotations +from datetime import timedelta + from django.core.files.base import ContentFile +from hub_platform.ai.agent_card import ensure_channel_agent +from hub_platform.ai.knowledge_categories import create_category, ensure_uncategorized_category +from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge from hub_platform.ai.models import ( AIAgent, - AIAgentStatus, - Knowledge, KnowledgeAttachment, LlmInvocation, LlmInvocationStatus, ) from hub_platform.channels.models import Channel +from hub_platform.identity.audit import record_audit_event from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, now, rng from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus +from hub_platform.integrations.services import IntegrationInput, create_integration from hub_platform.tenancy.context import TenantContext from hub_platform.tenancy.storage_quota import finalize_storage, reserve_storage +from hub_platform.webchat.widgets import ensure_widget def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("channels_ai") - organization = refs.organization + current = now() - 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", {}), - }, + llm = _ensure_llm_provider(context, refs, data["llmProvider"], current) + for item in data["agents"]: + _ensure_agent(context, refs, item, llm, current) + for item in data["connections"]: + _ensure_connection(context, refs, item, current) + _ensure_categories(context, refs, data.get("knowledgeCategories", []), parent=None) + for item in data.get("knowledge", []): + _ensure_knowledge(context, refs, item, current) + for item in data["agents"]: + _attach_agent_knowledge(refs, item) + _generate_usage(refs, data.get("usage"), current) + + +def _ensure_llm_provider(context: TenantContext, refs: DemoRefs, item: dict, current) -> Integration: + integration = Integration.objects.filter( + organization=refs.organization, kind=IntegrationKind.LLM_PROVIDER, name=item["name"] + ).first() + if integration is None: + integration = create_integration( + context=context, + data=IntegrationInput( + provider=item["provider"], + name=item["name"], + secret=item.get("secret", ""), + 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) + Integration.objects.filter(pk=integration.pk).update(status=item.get("status", IntegrationStatus.UNCHECKED)) + backdate(integration, current - timedelta(days=42)) + refs.integrations[item["key"]] = integration + return integration -def _ensure_channel(refs: DemoRefs, item: dict) -> None: - channel, _ = Channel.objects.get_or_create( - organization=refs.organization, +def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integration, current) -> None: + organization = refs.organization + channel, created = Channel.objects.get_or_create( + organization=organization, code=item["code"], defaults={ "name": item["name"], "group": refs.groups.get(item.get("group")), "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", ""), - "persona": item.get("persona", ""), - "tone": item.get("tone", ""), - "instructions": item.get("instructions", ""), - }, - ) + agent = ensure_channel_agent(channel) 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( + agent.status = item.get("aiStatus", agent.status) + agent.persona = item.get("persona", "") + agent.tone = item.get("tone", "") + agent.instructions = item.get("instructions", "") + agent.limits = item.get("limits", {}) + if agent.status in ("ACTIVE", "DISABLED"): + agent.provider_integration = llm + agent.model = (llm.config or {}).get("defaultModel", "") + agent.save() + when = current - timedelta(days=item.get("createdDaysAgo", 30)) + backdate(channel, when, "created_at", "updated_at") + backdate(agent, when, "created_at", "updated_at") + record_audit_event( organization=organization, - title=item["title"], - defaults={ - "category": category, - "description": item.get("description", ""), - "content": item.get("content", ""), - }, + actor=refs.users.get("anna"), + action="ai.agent_created", + object_type="Agent", + object_id=str(channel.id), + payload={"name": channel.name, "source": "demo"}, ) - refs.knowledge[item["key"]] = knowledge - if created: - for attachment_key in item.get("attachments", []): - _attach_knowledge_file(context, refs, knowledge, attachment_key) + refs.channels[item["key"]] = channel + refs.agents[item["key"]] = agent -def _attach_knowledge_file( - context: TenantContext, refs: DemoRefs, knowledge: Knowledge, filename: str -) -> None: +def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, current) -> None: + organization = refs.organization + channel = refs.channels.get(item.get("agent")) + integration = Integration.objects.filter( + organization=organization, provider=item["provider"], name=item["name"] + ).first() + if integration is None: + integration = create_integration( + context=context, + data=IntegrationInput( + provider=item["provider"], + name=item["name"], + secret=item.get("secret", ""), + config=item.get("config", {}), + ), + ) + Integration.objects.filter(pk=integration.pk).update( + channel=channel, + status=item.get("status", IntegrationStatus.OK), + last_error=item.get("lastError", ""), + last_checked_at=current - timedelta(minutes=7), + ) + integration.refresh_from_db() + backdate(integration, current - timedelta(days=item.get("createdDaysAgo", 20))) + # Веб-виджет создаётся вместе с WEB-подключением, у которого есть канал. + widget = ensure_widget(integration) + if widget is not None: + widget.status = "PUBLISHED" + widget.allowed_origins = list(item.get("config", {}).get("allowed_origins", [])) + widget.save(update_fields=["status", "allowed_origins", "updated_at"]) + refs.widgets[item["key"]] = widget + record_audit_event( + organization=organization, + actor=refs.users.get("anna"), + action="channels.connection_bound", + object_type="Agent", + object_id=str(channel.id) if channel else "", + payload={"integrationId": integration.id, "source": "demo"}, + ) + refs.integrations[item["key"]] = integration + + +def _ensure_categories(context: TenantContext, refs: DemoRefs, items: list[dict], *, parent) -> None: + from hub_platform.ai.knowledge_models import KnowledgeCategory + + for index, item in enumerate(items, start=1): + category = KnowledgeCategory.objects.filter( + organization=refs.organization, name=item["name"], parent=parent + ).first() + if category is None: + category = create_category( + context=context, name=item["name"], parent=parent, sort_order=index + ) + refs.knowledge_categories[item["key"]] = category + _ensure_categories(context, refs, item.get("children", []), parent=category) + + +def _ensure_knowledge(context: TenantContext, refs: DemoRefs, item: dict, current) -> None: + from hub_platform.ai.models import Knowledge + + organization = refs.organization + knowledge = Knowledge.objects.filter(organization=organization, title=item["title"]).first() + if knowledge is None: + category = refs.knowledge_categories.get(item.get("category")) or ensure_uncategorized_category(organization) + knowledge = create_knowledge( + context=context, + data=KnowledgeInput( + title=item["title"], + description=item.get("description", ""), + content=item.get("content", ""), + is_enabled=item.get("isEnabled", True), + category_id=category.id, + ), + ) + when = current - timedelta(days=item.get("createdDaysAgo", 20)) + backdate(knowledge, when, "created_at", "updated_at") + for attachment in item.get("attachments", []): + _attach_knowledge_file(context, refs, knowledge, attachment) + refs.knowledge[item["key"]] = knowledge + + +def _attach_knowledge_file(context: TenantContext, refs: DemoRefs, knowledge, spec) -> None: + if isinstance(spec, str): + spec = {"file": spec} + filename = spec["file"] 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) + payload = manifest.media_bytes(filename) + text_source = spec.get("text", filename) + extracted_text = manifest.media_text(text_source) if text_source.rsplit(".", 1)[-1] in ("md", "txt") else "" suffix = filename.rsplit(".", 1)[-1].lower() - content_type = "text/markdown" if suffix == "md" else "application/octet-stream" + content_type = spec.get("contentType") or { + "md": "text/markdown", + "txt": "text/plain", + "pdf": "application/pdf", + }.get(suffix, "application/octet-stream") + reservation_key = f"demo-knowledge:{knowledge.id}:{filename}" + reserve_storage(context=context, expected_bytes=len(payload), idempotency_key=reservation_key) 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), + size=len(payload), + extracted_text=extracted_text, ) - attachment.file.save(filename, ContentFile(data), save=True) - finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(data)) + attachment.file.save(filename, ContentFile(payload), save=True) + finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(payload)) + if extracted_text: + from hub_platform.ai.indexing import reindex_knowledge + + reindex_knowledge(knowledge) -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), - }, - ) +def _attach_agent_knowledge(refs: DemoRefs, item: dict) -> None: + agent: AIAgent = refs.agents[item["key"]] + for key in item.get("knowledge", []): + knowledge = refs.knowledge.get(key) + if knowledge is not None: + agent.knowledge_items.add(knowledge) + # Статьи портала привязываются позже — после загрузчика поддержки (см. support.py). + refs.agent_article_links[item["key"]] = list(item.get("portalArticles", [])) + + +def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None: + """История LlmInvocation за N дней для раздела «Использование AI».""" + if not spec: + return + random = rng() + if LlmInvocation.objects.filter(channel__organization=refs.organization, purpose="agent_chat").exists(): + return + failures_left = spec.get("failuresTotal", 0) + days = spec.get("days", 30) + for day in range(days, -1, -1): + day_start = (current - timedelta(days=day)).replace(hour=9, minute=0, second=0, microsecond=0) + for agent_key, (low, high) in spec.get("chatPerDay", {}).items(): + channel = refs.channels.get(agent_key) + if channel is None: + continue + for _ in range(random.randint(low, high)): + prompt = random.randint(*spec["promptTokens"]) + completion = random.randint(*spec["completionTokens"]) + failed = failures_left > 0 and random.random() < 0.02 + if failed: + failures_left -= 1 + invocation = LlmInvocation.objects.create( + channel=channel, + product=channel.product, + purpose="agent_chat", + operation="chat", + model=spec["model"], + prompt_tokens=prompt, + completion_tokens=0 if failed else completion, + total_tokens=prompt + (0 if failed else completion), + cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]), + latency_ms=random.randint(900, 4200), + status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS, + error="Provider timeout after 30s" if failed else "", + ) + backdate(invocation, day_start + timedelta(minutes=random.randint(0, 660))) + low, high = spec.get("embeddingPerDay", [0, 0]) + channel = refs.channels.get("consultant") + for _ in range(random.randint(low, high)): + tokens = random.randint(200, 900) + invocation = LlmInvocation.objects.create( + channel=channel, + product=None, + purpose="knowledge_index", + operation="embedding", + model="text-embedding-3-small", + prompt_tokens=tokens, + completion_tokens=0, + total_tokens=tokens, + cost_micros=int(tokens * 0.02), + latency_ms=random.randint(200, 900), + status=LlmInvocationStatus.SUCCESS, + ) + backdate(invocation, day_start + timedelta(minutes=random.randint(0, 660))) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/common.py b/apps/backend/hub_platform/identity/demo_seed/loaders/common.py new file mode 100644 index 0000000..3f8d0a2 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/common.py @@ -0,0 +1,67 @@ +"""Общие помощники загрузчиков демо-сида. + +Время в манифестах относительное (``minutesAgo`` / ``hoursAgo`` / ``daysAgo``): +демо должно выглядеть живым в момент установки («ждёт 6 мин», «вчера»). +Поля ``auto_now_add`` нельзя задать при создании — их откатывают точечным +``UPDATE`` после сохранения. +""" + +from __future__ import annotations + +import random +from datetime import datetime, timedelta + +from django.db import models +from django.utils import timezone + +# Детерминированный генератор: повторная установка даёт те же «случайные» ряды. +DEMO_SEED = 20260905 + + +def rng() -> random.Random: + return random.Random(DEMO_SEED) + + +def moment(item: dict, now: datetime, prefix: str = "") -> datetime | None: + """Момент из ключей ``MinutesAgo`` / ``HoursAgo`` / ``DaysAgo``. + + Без префикса ключи — ``minutesAgo``, ``hoursAgo``, ``daysAgo``. Возвращает + ``None``, если ни одного ключа нет. + """ + + def key(name: str) -> str: + return f"{prefix}{name[0].upper()}{name[1:]}" if prefix else name + + delta = timedelta() + found = False + for name, unit in (("minutesAgo", "minutes"), ("hoursAgo", "hours"), ("daysAgo", "days")): + value = item.get(key(name)) + if value is not None: + delta += timedelta(**{unit: float(value)}) + found = True + return now - delta if found else None + + +def backdate(instance: models.Model, when: datetime | None, *fields: str) -> None: + """Переписывает временные поля записи (created_at и т. п.) без сигналов.""" + if when is None: + return + names = fields or ("created_at",) + values = {name: when for name in names if _has_field(instance, name)} + if not values: + return + type(instance)._base_manager.filter(pk=instance.pk).update(**values) + for name, value in values.items(): + setattr(instance, name, value) + + +def _has_field(instance: models.Model, name: str) -> bool: + try: + instance._meta.get_field(name) + except Exception: # noqa: BLE001 + return False + return True + + +def now() -> datetime: + return timezone.now() diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py b/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py index 03f932c..03939e8 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py @@ -1,54 +1,95 @@ -"""Диалоги: контакты, идентичности подключений, диалоги, сообщения, прочтения.""" +"""Диалоги: контакты, идентичности, метки, шаблоны, диалоги во всех состояниях, +сообщения (текст, системные, контакт, голосовые), прочтения, веб-гость.""" from __future__ import annotations +import logging from datetime import timedelta -from django.utils import timezone +from django.core.files.base import ContentFile from hub_platform.conversations.models import ( ConnectionIdentity, Contact, - ControlMode, + ConversationLabel, Conversation, ConversationRead, - ExpectedResponder, - LifecycleState, Message, MessageAuthor, + MessageKind, + ReplyTemplate, + TranscriptStatus, ) from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.tenancy.context import TenantContext +from hub_platform.webchat.services import issue_session + +logger = logging.getLogger(__name__) + +AVATAR_URL = "/api/v1/demo-media/avatars/{name}" def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("conversations") organization = refs.organization - now = timezone.now() + current = now() for item in data["contacts"]: - contact, _ = Contact.objects.update_or_create( + contact, created = Contact.objects.get_or_create( organization=organization, name=item["name"], - defaults={"phone": item.get("phone", ""), "avatar_url": item.get("avatarUrl", "")}, + defaults={ + "phone": item.get("phone", ""), + "avatar_url": AVATAR_URL.format(name=item["avatar"]) if item.get("avatar") else "", + }, ) + if created: + backdate(contact, moment(item, current, "created")) refs.contacts[item["key"]] = contact for item in data["connectionIdentities"]: _ensure_identity(refs, item) - for item in data["conversations"]: - _ensure_conversation(refs, item, now) + for item in data.get("labels", []): + label, _ = ConversationLabel.objects.get_or_create( + organization=organization, name=item["name"], defaults={"color": item.get("color", "")} + ) + refs.labels[item["key"]] = label + + for item in data.get("replyTemplates", []): + ReplyTemplate.objects.get_or_create( + organization=organization, title=item["title"], defaults={"text": item["text"]} + ) + + # Диалоги с previousConversation ссылаются на более ранние — создаём в два прохода. + pending = list(data["conversations"]) + created_keys: set[str] = set() + while pending: + progressed = False + for item in list(pending): + previous_key = item.get("previousConversation") + if previous_key and previous_key not in created_keys: + continue + _ensure_conversation(context, refs, item, current) + created_keys.add(item["key"]) + pending.remove(item) + progressed = True + if not progressed: + raise manifest.ManifestError( + "conversations: previousConversation forms a cycle or points to an unknown key" + ) def _ensure_identity(refs: DemoRefs, item: dict) -> None: connection = refs.integrations[item["connection"]] contact = refs.contacts[item["contact"]] - identity, _ = ConnectionIdentity.objects.update_or_create( + identity, _ = ConnectionIdentity.objects.get_or_create( connection=connection, external_user_id=item["externalUserId"], defaults={ + "organization": refs.organization, "contact": contact, "display_name": item.get("displayName", ""), "username": item.get("username", ""), @@ -57,60 +98,117 @@ def _ensure_identity(refs: DemoRefs, item: dict) -> None: 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 +def _web_guest(context: TenantContext, refs: DemoRefs, item: dict) -> tuple[Contact, ConnectionIdentity, str]: + """Анонимный гость виджета: настоящая веб-сессия через сервис webchat.""" + widget = refs.widgets[item["connection"]] + session = issue_session(context=context, widget=widget) + if session is None: + raise manifest.ManifestError("web widget has no channel — cannot issue guest session") + identity = ConnectionIdentity.objects.get( + connection=refs.integrations[item["connection"]], external_user_id=session["sessionId"] + ) + return identity.contact, identity, session["sessionId"] - conversation, _ = Conversation.objects.get_or_create( + +def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, current) -> None: + organization = refs.organization + channel = refs.channels[item["agent"]] + connection = refs.integrations[item["connection"]] + if item.get("webGuest"): + contact, identity, external_chat_id = _web_guest(context, refs, item) + else: + contact = refs.contacts.get(item.get("contact")) + identity = refs.identities.get(item.get("identity")) + external_chat_id = item["externalChatId"] + + existing = Conversation.objects.filter( + organization=organization, channel=channel, external_chat_id=external_chat_id + ).order_by("-id") + # Один и тот же чат может иметь несколько диалогов (история контакта): + # ключ уникальности — чат + момент создания. + created_at = moment(item, current, "created") or current + conversation = existing.filter(created_at=created_at).first() + if conversation is not None: + refs.conversations[item["key"]] = conversation + return + + snapshot = refs.identity_snapshots.get(item.get("supportIdentitySnapshot")) + conversation = Conversation.objects.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", {}), - }, + connection=connection, + # Ровно одна личность: снимок из личного кабинета либо контакт. + contact=None if snapshot is not None else contact, + support_identity_snapshot=snapshot, + external_chat_id=external_chat_id, + transport_meta=item.get("transportMeta", {}), + lifecycle=item.get("lifecycle", "OPEN"), + control_mode=item.get("controlMode", "AI"), + expected_responder=item.get("expectedResponder", "AI"), + group=refs.groups.get(item.get("group")), + assigned_operator=refs.users.get(item.get("assignedOperator")), + priority=item.get("priority", "NONE"), + note=item.get("note", ""), + previous_conversation=refs.conversations.get(item.get("previousConversation")), ) - Conversation.objects.filter(pk=conversation.pk).update( - last_activity_at=now - timedelta(minutes=item.get("lastActivityMinutesAgo", 0)) - ) - conversation.refresh_from_db() + for label_key in item.get("labels", []): + label = refs.labels.get(label_key) + if label is not None: + conversation.labels.add(label) + archived_at = moment(item, current, "archived") + if archived_at is not None: + Conversation.objects.filter(pk=conversation.pk).update(archived_at=archived_at) + last_activity = moment(item, current, "lastActivity") or created_at + backdate(conversation, created_at, "created_at") + backdate(conversation, last_activity, "last_activity_at") 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) + last_message: Message | None = None + for index, spec in enumerate(item.get("messages", []), start=1): + message = _create_message(context, refs, conversation, spec, index, current) + if message is not None: + last_message = message - 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}, - ) + if last_message is not None: + for reader_key in item.get("readBy", []): + 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}, + ) + + +def _create_message(context: TenantContext, refs: DemoRefs, conversation: Conversation, spec: dict, index: int, current) -> Message | None: + author_type = spec["author"] + kind = spec.get("kind", MessageKind.TEXT) + voice = spec.get("voice") + if kind == MessageKind.VOICE and voice is not None: + filename = f"voice/{voice['file']}" + if not manifest.media_exists(filename): + logger.warning("Demo voice file %s is missing — message skipped", filename) + return None + message = Message.objects.create( + conversation=conversation, + author_type=author_type, + author_user=refs.users.get(spec.get("authorUser")) if author_type == MessageAuthor.OPERATOR else None, + kind=kind, + text=spec.get("text", ""), + content_html=spec.get("contentHtml", ""), + external_id=f"demo-{conversation.id}-{index}", + ) + if kind == MessageKind.VOICE and voice is not None: + payload = manifest.media_bytes(f"voice/{voice['file']}") + content_type = voice.get("contentType", "audio/ogg") + suffix = voice["file"].rsplit(".", 1)[-1] + message.audio_content_type = content_type + message.duration_seconds = int(voice.get("durationSeconds", 0)) + message.transcript = voice.get("transcript", "") + message.transcript_status = voice.get("transcriptStatus", TranscriptStatus.NONE) + message.audio.save(f"voice.{suffix}", ContentFile(payload), save=False) + message.save( + update_fields=["audio", "audio_content_type", "duration_seconds", "transcript", "transcript_status"] + ) + backdate(message, moment(spec, current) or current - timedelta(minutes=1)) + return message diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py b/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py index 731f54b..c4c7a21 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py @@ -1,89 +1,135 @@ -"""Базовый слой: организация, группы, пользователи, участия, аудит. +"""Базовый слой: группы, демо-сотрудники, участия, приглашения. -Использует существующие идемпотентные хелперы (``ensure_*``). +Организация и её владелец не создаются — демо ставится в организацию +установщика (``context.organization``). Пароль всех демо-учёток один +(``demoPassword`` манифеста): установщик входит под любым из них. """ from __future__ import annotations -from hub_platform.ai.knowledge_categories import ensure_uncategorized_category +from datetime import timedelta + from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.auth.totp_utils import _generate_totp_secret from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember -from hub_platform.identity.models import ( - HumanUser, - Organization, - OrganizationMembership, -) +from hub_platform.identity.invitation_service import issue_invitation +from hub_platform.identity.models import HumanUser, OrganizationMembership 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 + organization = refs.organization + current = now() for item in data.get("groups", []): - group, _ = EmployeeGroup.objects.get_or_create( - organization=organization, name=item["name"] - ) + group, _ = EmployeeGroup.objects.get_or_create(organization=organization, name=item["name"]) refs.groups[item["key"]] = group - ensure_uncategorized_category(organization) - - _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 - + password = data["demoPassword"] 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 + _ensure_account(refs, item, password, current) - membership, _ = OrganizationMembership.objects.get_or_create( - user=user, + for item in data.get("invitations", []): + created_by = refs.memberships.get(item.get("createdBy")) + expires_in = item.get("expiresInDays", 7) + issued = issue_invitation( organization=organization, - defaults={ - "role": item["role"], - "position_title": item.get("positionTitle", ""), - "phone": item.get("phone", ""), - }, + email=item["email"], + role=item["role"], + expires_at=current + timedelta(days=max(expires_in, 1)), + created_by=created_by, ) - refs.memberships[item["key"]] = membership + if expires_in < 0: + # Просроченное приглашение: выдано неделю назад, срок вышел. Валидация + # модели не даёт создать его сразу просроченным — откатываем даты. + backdate( + issued.invitation, + current - timedelta(days=7), + "created_at", + ) + type(issued.invitation)._base_manager.filter(pk=issued.invitation.pk).update( + expires_at=current + timedelta(days=expires_in) + ) - group = refs.groups.get(item.get("group", "")) + record_audit_event( + organization=organization, + actor=refs.users.get("anna"), + action="demo.installed_accounts", + object_type="Organization", + object_id=str(organization.public_id), + payload={"accounts": len(refs.users)}, + ) + + +def _ensure_account(refs: DemoRefs, item: dict, password: str, current) -> None: + organization = refs.organization + email = HumanUser.objects.normalize_email(item["email"]) + user, created = HumanUser.objects.get_or_create( + email=email, + defaults={ + "full_name": item["fullName"], + "ui_theme": item.get("uiTheme", "SYSTEM"), + "ui_accent": item.get("uiAccent", ""), + }, + ) + if created: + user.set_password(password) + if item.get("totpEnabled"): + user.totp_enabled = True + user.totp_secret = _generate_totp_secret() + user.save() + refs.users[item["key"]] = user + + membership, membership_created = OrganizationMembership.objects.get_or_create( + user=user, + organization=organization, + defaults={ + "role": item["role"], + "position_title": item.get("positionTitle", ""), + "phone": item.get("phone", ""), + "blocked_at": current - timedelta(days=21) if item.get("blocked") else None, + }, + ) + if membership_created: + backdate(membership, current - timedelta(days=item.get("createdDaysAgo", 45))) + refs.memberships[item["key"]] = membership + + for group_key in item.get("groups", []): + group = refs.groups.get(group_key) if group is not None: EmployeeGroupMember.objects.get_or_create( organization=organization, group=group, employee=membership ) + + if membership_created: + record_audit_event( + organization=organization, + actor=refs.users.get("anna") or user, + action="identity.employee_created", + object_type="OrganizationMembership", + object_id=str(membership.id), + payload={"email": email, "role": item["role"], "source": "demo"}, + ) + if item.get("blocked"): + record_audit_event( + organization=organization, + actor=refs.users.get("anna") or user, + action="identity.employee_blocked", + object_type="OrganizationMembership", + object_id=str(membership.id), + payload={"source": "demo"}, + ) + # Демо-сотрудники «входили в систему» — история для аудита и «последний вход». + login_at = moment(item, current, "lastLogin") or current - timedelta(hours=3) + if not item.get("blocked"): + HumanUser.objects.filter(pk=user.pk).update(last_login=login_at) + record_audit_event( + organization=organization, + actor=user, + action="identity.login_succeeded", + payload={"source": "demo"}, + ) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py b/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py index ff838f5..1bb0bba 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py @@ -1,24 +1,29 @@ -"""Операционные данные: звонки, уведомления, период учёта использования.""" +"""Операционные данные: звонки (с метриками, участниками и приглашением), +уведомления всех типов, привязки уведомителя к мессенджеру.""" from __future__ import annotations +import hashlib from datetime import timedelta -from django.utils import timezone - from hub_platform.calls.models import ( CallConnectionType, CallEndedBy, + CallInvite, CallMetric, CallParticipant, CallSession, CallStatus, + InviteDeliveryStatus, ParticipantConnectionState, ParticipantSide, ) from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.notifications.models import ( + MessengerBinding, + MessengerBindingCode, Notification, NotificationAudience, NotificationLevel, @@ -26,105 +31,151 @@ from hub_platform.notifications.models import ( ) from hub_platform.tenancy.context import TenantContext +FINISHED = { + CallStatus.ENDED, + CallStatus.DECLINED, + CallStatus.CANCELLED, + CallStatus.MISSED, + CallStatus.FAILED, + CallStatus.EXPIRED, +} + def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("operations") - _ensure_calls(refs, data.get("calls", [])) - _ensure_notifications(refs, data.get("notifications", [])) - - -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, + current = now() + for item in data.get("calls", []): + _ensure_call(refs, item, current) + for item in data.get("notifications", []): + _ensure_notification(refs, item, current) + for item in data.get("messengerBindings", []): + MessengerBinding.objects.get_or_create( + user=refs.users[item["user"]], + integration=refs.integrations[item["connection"]], defaults={ "organization": refs.organization, - "initiated_by": initiator, - "status": item.get("status", CallStatus.ENDED), - "delivery_connection": refs.integrations.get(item.get("deliveryConnection")), + "external_chat_id": item["externalChatId"], + "push_types": item.get("pushTypes", []), + }, + ) + for item in data.get("messengerBindingCodes", []): + MessengerBindingCode.objects.get_or_create( + code=item["code"], + defaults={ + "organization": refs.organization, + "user": refs.users[item["user"]], + "integration": refs.integrations[item["connection"]], + "expires_at": current + timedelta(minutes=item.get("expiresInMinutes", 15)), }, ) - 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: +def _ensure_call(refs: DemoRefs, item: dict, current) -> None: + conversation = refs.conversations[item["conversation"]] + initiator = refs.users[item["initiatedBy"]] + if CallSession.objects.filter(conversation=conversation, initiated_by=initiator, kind=item.get("kind", "AUDIO")).exists(): + return + started = moment(item, current) or current - timedelta(minutes=10) + status = item.get("status", CallStatus.ENDED) + session = CallSession.objects.create( + organization=refs.organization, + conversation=conversation, + initiated_by=initiator, + status=status, + kind=item.get("kind", "AUDIO"), + delivery_connection=refs.integrations.get(item.get("deliveryConnection")), + ) + backdate(session, started, "requested_at") + if 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) + session.save(update_fields=["accepted_at", "connected_at", "ended_at", "ended_by", "updated_at"]) + _ensure_metric(session, item) + elif status in (CallStatus.DECLINED, CallStatus.MISSED, CallStatus.EXPIRED, CallStatus.CANCELLED): + session.ended_at = started + timedelta(seconds=45) + session.ended_by = item.get("endedBy", "") + session.save(update_fields=["ended_at", "ended_by", "updated_at"]) + + identity = refs.identities.get(item.get("contactIdentity")) + if identity is not None: + CallParticipant.objects.create( + organization=refs.organization, + call_session=session, + side=ParticipantSide.CUSTOMER, + connection_identity=identity, + joined_at=session.connected_at, + left_at=session.ended_at, + last_connection_state=ParticipantConnectionState.DISCONNECTED, + ) + CallParticipant.objects.create( + organization=refs.organization, + call_session=session, + side=ParticipantSide.STAFF, + user=initiator, + joined_at=session.connected_at, + left_at=session.ended_at, + last_connection_state=ParticipantConnectionState.DISCONNECTED, + ) + invite = item.get("invite") + if invite is not None and identity is not None: + token_hash = hashlib.sha256(f"demo-invite-{session.id}".encode()).hexdigest() + call_invite = CallInvite.objects.create( + organization=refs.organization, + call_session=session, + connection_identity=identity, + token_hash=token_hash, + expires_at=started + timedelta(minutes=invite.get("expiredMinutesAfter", 2)), + opened_at=None, + responded_at=None, + delivery_status=InviteDeliveryStatus.SENT + if invite.get("deliveryStatus", "SENT") in ("SENT", "DELIVERED") + else invite.get("deliveryStatus"), + ) + backdate(call_invite, started) + refs.calls[item["key"]] = session + + +def _ensure_metric(session: CallSession, item: dict) -> None: metrics = item.get("metrics") if not metrics: return - CallMetric.objects.update_or_create( + CallMetric.objects.create( + organization=session.organization, 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"), - }, + 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={ - "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_notification(refs: DemoRefs, item: dict, current) -> None: + conversation = refs.conversations.get(item.get("targetConversation")) + dedup_key = f"demo:{item['type']}:{item.get('targetConversation') or item['title']}" + notification, created = Notification.objects.get_or_create( + organization=refs.organization, + dedup_key=dedup_key, + defaults={ + "type": item["type"], + "level": item.get("level", NotificationLevel.INFO), + "title": item["title"], + "body": item.get("body", ""), + "target_route": item.get("targetRoute", ""), + "target_id": str(conversation.id) if conversation is not None else item.get("targetId", ""), + "audience": item.get("audience", NotificationAudience.ALL), + "recipient_user": refs.users.get(item.get("recipientUser")), + }, + ) + if not created: + return + backdate(notification, moment(item, current) or current) + for reader_key in item.get("readBy", []): + user = refs.users.get(reader_key) + if user is not None: + NotificationRead.objects.get_or_create( + notification=notification, user=user, defaults={"organization": refs.organization} + ) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py b/apps/backend/hub_platform/identity/demo_seed/loaders/support.py index aca219e..c51aa41 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/support.py @@ -1,12 +1,12 @@ -"""Поддержка: контракты, снимки идентичности, портал, статьи и ревизии.""" +"""Поддержка: контракты, снимки личности, портал помощи, статьи (опубликованные, +черновик, архив, вторая ревизия), оценки читателей, статьи у агентов.""" 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.loaders.common import backdate, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.support.models import ( ContractStatus, @@ -14,16 +14,19 @@ from hub_platform.support.models import ( SupportIdentitySnapshot, ) from hub_platform.support_portals.content_services import ( + add_revision, + archive_article, 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.models import PortalCategory, SupportPortal +from hub_platform.support_portals.portal_services import ( + PortalInput, + create_portal, + replace_product_links, + set_portal_status, ) from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus from hub_platform.tenancy.context import TenantContext @@ -31,9 +34,11 @@ from hub_platform.tenancy.context import TenantContext def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("support") + current = now() _ensure_contracts(refs, data.get("contracts", [])) - _ensure_snapshots(refs, data.get("identitySnapshots", [])) - _ensure_portal(context, refs, data.get("portal")) + _ensure_snapshots(refs, data.get("identitySnapshots", []), current) + _ensure_portal(context, refs, data.get("portal"), current) + _link_agent_articles(refs) def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None: @@ -47,11 +52,13 @@ def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None: "status": item.get("status", ContractStatus.ACTIVE), }, ) + support_channel = refs.channels.get("support") + if support_channel is not None: + contract.allowed_channels.add(support_channel) refs.support_contracts[item["key"]] = contract -def _ensure_snapshots(refs: DemoRefs, items: list[dict]) -> None: - now = timezone.now() +def _ensure_snapshots(refs: DemoRefs, items: list[dict], current) -> None: for item in items: contract = refs.support_contracts[item["contract"]] snapshot, _ = SupportIdentitySnapshot.objects.get_or_create( @@ -68,41 +75,43 @@ def _ensure_snapshots(refs: DemoRefs, items: list[dict]) -> None: "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)), + "token_issued_at": current - timedelta(days=item.get("issuedDaysAgo", 1)), + "token_expires_at": current + timedelta(days=item.get("expiresInDays", 30)), }, ) refs.identity_snapshots[item["key"]] = snapshot -def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None) -> None: +def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None, current) -> None: if not portal_data: return - organization = refs.organization - - portal, portal_created = SupportPortal.objects.get_or_create( - slug=portal_data["slug"], - defaults={ - "organization": organization, - "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}, + portal = SupportPortal.objects.filter(organization=refs.organization, slug=portal_data["slug"]).first() + if portal is None: + widget = refs.widgets.get(portal_data.get("widgetConnection")) + portal = create_portal( + context=context, + data=PortalInput( + slug=portal_data["slug"], + name=portal_data["name"], + default_locale=portal_data.get("locale", "ru"), + widget_id=widget.id if widget is not None else None, + ), ) + backdate(portal, current - timedelta(days=28), "created_at") + # Виджет портала — анонимный (чат на странице помощи); виджет продукта — + # авторизованный (личный кабинет), через него идут обращения с личностью. + account_widget = refs.widgets.get(portal_data.get("accountWidgetConnection")) + links = [] + for product_code in portal_data.get("products", []): + product = refs.products[product_code] + links.append( + { + "productId": product.id, + "supportWidgetId": account_widget.id if account_widget is not None else None, + } + ) + if links: + replace_product_links(context=context, portal=portal, links=links) categories: dict[str, PortalCategory] = {} for item in portal_data.get("categories", []): @@ -121,30 +130,67 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N categories[item["slug"]] = category for item in portal_data.get("articles", []): - _ensure_article(context, portal, categories, item) + _ensure_article(context, refs, portal, categories, item, current) + + target_status = portal_data.get("status", PortalStatus.PUBLISHED) + if portal.status != target_status: + set_portal_status(context=context, portal=portal, status=target_status) + refs.portal = portal -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) +def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPortal, categories, item: dict, current) -> None: + article = portal.articles.filter(slug=item["slug"]).first() + if article is None: + article = create_article( + context=context, + portal=portal, + 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", ""), + }, + ) + published_at = current - timedelta(days=item.get("publishedDaysAgo", 7)) + backdate(article, published_at - timedelta(days=1), "created_at") + first = article.revisions.order_by("revision").first() + backdate(first, published_at - timedelta(days=1), "created_at") + status = item.get("status", ArticleStatus.PUBLISHED) + if status in (ArticleStatus.PUBLISHED, ArticleStatus.ARCHIVED): + publish_revision(article=article, revision_id=first.id) + first.refresh_from_db() + backdate(first, published_at, "published_at") + second = item.get("secondRevision") + if second and status == ArticleStatus.PUBLISHED: + revision = add_revision( + context=context, + article=article, + data={ + "title": item["title"], + "summary": second.get("summary", item.get("summary", "")), + "content": item.get("content", "") + second.get("contentAppend", ""), + }, + ) + publish_revision(article=article, revision_id=revision.id) + when = current - timedelta(days=second.get("daysAgo", 3)) + backdate(revision, when, "created_at", "published_at") + for _ in range(item.get("helpfulVotes", 0)): + record_feedback(article, helpful=True) + for _ in range(item.get("unhelpfulVotes", 0)): + record_feedback(article, helpful=False) + if status == ArticleStatus.ARCHIVED: + archive_article(article) + refs.portal_articles[item["slug"]] = article + + +def _link_agent_articles(refs: DemoRefs) -> None: + for agent_key, slugs in refs.agent_article_links.items(): + agent = refs.agents.get(agent_key) + if agent is None: + continue + for slug in slugs: + article = refs.portal_articles.get(slug) + if article is not None and article.status == ArticleStatus.PUBLISHED: + agent.portal_articles.add(article) diff --git a/apps/backend/hub_platform/identity/demo_seed/manifest.py b/apps/backend/hub_platform/identity/demo_seed/manifest.py index 32b404b..9f56124 100644 --- a/apps/backend/hub_platform/identity/demo_seed/manifest.py +++ b/apps/backend/hub_platform/identity/demo_seed/manifest.py @@ -63,21 +63,26 @@ def media_path(filename: str) -> Path: def media_text(filename: str) -> str: - """Текстовое содержимое вложения (для SVG/Markdown).""" + """Текстовое содержимое вложения (для Markdown/текста).""" return media_path(filename).read_text(encoding="utf-8") +def media_bytes(filename: str) -> bytes: + """Бинарное содержимое вложения (PDF, аудио, изображения).""" + return media_path(filename).read_bytes() + + +def media_exists(filename: str) -> bool: + return (MEDIA_DIR / filename).is_file() + + 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") + if not payload.get("demoPassword") or not isinstance(payload.get("accounts"), list): + raise ManifestError("organization manifest requires demoPassword and accounts") def _validate_section(payload: dict | list, name: str) -> None: diff --git a/apps/backend/hub_platform/identity/demo_seed/orchestrator.py b/apps/backend/hub_platform/identity/demo_seed/orchestrator.py index 14f3b19..f5274f6 100644 --- a/apps/backend/hub_platform/identity/demo_seed/orchestrator.py +++ b/apps/backend/hub_platform/identity/demo_seed/orchestrator.py @@ -2,7 +2,7 @@ Создаёт полный, взаимосвязанный набор выдуманных данных одной организации, будто система уже работает. Запускается строго в порядке FK-зависимостей: -foundation → catalog → channels/ai → conversations → support → operations. +foundation → catalog → channels/ai → support → conversations → operations. Идемпотентен на каждом шаге. """ @@ -32,18 +32,26 @@ from hub_platform.tenancy.context import TenantContext __all__ = ["DemoRefs", "run_demo_seed"] -def run_demo_seed(context: TenantContext, *, refs: DemoRefs | None = None) -> DemoRefs: +def run_demo_seed( + context: TenantContext, *, refs: DemoRefs | None = None, recorder: object | None = None +) -> DemoRefs: """Запускает все loaders в порядке зависимостей. Предполагается вызов внутри ``tenant_atomic(context)``: tenant-контекст - активирован, RLS и storage-guard работают. + активирован, RLS и storage-guard работают. ``recorder`` — реестр + установленных записей (``registry.DemoRecorder``) для точного удаления; + loaders обращаются к нему только для записей, созданных без сигналов. """ refs = refs or DemoRefs() + refs.organization = context.organization + refs.recorder = recorder foundation_loader.load(context, refs) catalog_loader.load(context, refs) channels_ai_loader.load(context, refs) - conversations_loader.load(context, refs) + # Поддержка раньше диалогов: диалог портала ссылается на снимок личности, + # а статьи портала привязываются к агентам. support_loader.load(context, refs) + conversations_loader.load(context, refs) operations_loader.load(context, refs) return refs diff --git a/apps/backend/hub_platform/identity/demo_seed/refs.py b/apps/backend/hub_platform/identity/demo_seed/refs.py index 8a21495..ff4a50f 100644 --- a/apps/backend/hub_platform/identity/demo_seed/refs.py +++ b/apps/backend/hub_platform/identity/demo_seed/refs.py @@ -18,15 +18,24 @@ class DemoRefs: """ organization: object | None = None + recorder: object | None = None groups: dict[str, object] = field(default_factory=dict) memberships: dict[str, object] = field(default_factory=dict) users: dict[str, object] = field(default_factory=dict) products: dict[str, object] = field(default_factory=dict) integrations: dict[str, object] = field(default_factory=dict) channels: dict[str, object] = field(default_factory=dict) + agents: dict[str, object] = field(default_factory=dict) + widgets: dict[str, object] = field(default_factory=dict) + knowledge_categories: dict[str, object] = field(default_factory=dict) knowledge: dict[str, object] = field(default_factory=dict) + agent_article_links: dict[str, list[str]] = field(default_factory=dict) + labels: dict[str, object] = field(default_factory=dict) + portal_articles: 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) + calls: dict[str, object] = field(default_factory=dict) + portal: object | None = None diff --git a/apps/backend/hub_platform/identity/demo_seed/registry.py b/apps/backend/hub_platform/identity/demo_seed/registry.py new file mode 100644 index 0000000..a3e8bf3 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/registry.py @@ -0,0 +1,164 @@ +"""Реестр созданных сидом записей и их точное удаление. + +Запись: на время установки к ``post_save`` подключается приёмник, который +регистрирует каждую созданную (``created=True``) запись любой модели в +``DemoRecord`` с порядковым номером. Сид создаёт данные через сервисы +системы, поэтому реестр покрывает и побочные записи (аудит, уведомления, +фрагменты знаний, сессии веб-чата). + +Удаление: записи удаляются по одной в порядке, обратном созданию — так +зависимые строки уходят раньше родителей, а PROTECT-связи не мешают. +Каскадно удалённые ранее строки просто пропускаются. Файлы FileField +удаляются из хранилища вместе с записью. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager + +from django.contrib.contenttypes.models import ContentType +from django.db import models, transaction +from django.db.models.deletion import ProtectedError +from django.db.models.signals import post_save + +from hub_platform.identity.demo_models import DemoDataset, DemoRecord + +logger = logging.getLogger(__name__) + +# Модели, которые реестр не регистрирует: сам реестр и типы контента. +_IGNORED_MODELS = {DemoDataset, DemoRecord, ContentType} + + +class DemoRecorder: + """Собирает созданные записи в ``DemoRecord`` дата-сета.""" + + def __init__(self, dataset: DemoDataset) -> None: + self.dataset = dataset + self.sequence = ( + DemoRecord.objects.filter(dataset=dataset).order_by("-sequence") + .values_list("sequence", flat=True) + .first() + or 0 + ) + self._content_types: dict[type[models.Model], ContentType] = {} + + def _content_type(self, model: type[models.Model]) -> ContentType: + cached = self._content_types.get(model) + if cached is None: + cached = ContentType.objects.get_for_model(model, for_concrete_model=True) + self._content_types[model] = cached + return cached + + def on_post_save(self, sender, instance, created, raw=False, **kwargs) -> None: # noqa: ANN001 + if not created or raw or sender in _IGNORED_MODELS or sender._meta.abstract: + return + if sender._meta.app_label == "sessions": + return + self.sequence += 1 + DemoRecord.objects.create( + organization_id=self.dataset.organization_id, + dataset=self.dataset, + content_type=self._content_type(sender), + object_id=str(instance.pk), + sequence=self.sequence, + ) + + def record(self, instance: models.Model) -> None: + """Явная регистрация записи, созданной без сигнала (bulk_create).""" + self.on_post_save(type(instance), instance, True) + + +@contextmanager +def recording(dataset: DemoDataset) -> Iterator[DemoRecorder]: + recorder = DemoRecorder(dataset) + post_save.connect(recorder.on_post_save, dispatch_uid=f"demo-recorder-{dataset.pk}", weak=False) + try: + yield recorder + finally: + post_save.disconnect(dispatch_uid=f"demo-recorder-{dataset.pk}") + + +def _delete_files(instance: models.Model) -> None: + for field in instance._meta.get_fields(): + if isinstance(field, models.FileField): + file = getattr(instance, field.name, None) + if file and file.name: + try: + file.delete(save=False) + except Exception: # noqa: BLE001 — файл не должен блокировать удаление записи + logger.warning("Demo file %s was not deleted", file.name, exc_info=True) + + +def _detach_demo_references(instance: models.Model, demo_keys: set[tuple[int, str]]) -> bool: + """Обнуляет nullable-FK записи на другие демо-объекты — разрывает циклы + PROTECT (статья ↔ опубликованная ревизия). Только демо-объекты: реальные + данные не трогаются. Возвращает ``True``, если что-то обнулено.""" + updates: dict[str, None] = {} + for field in instance._meta.get_fields(): + if not isinstance(field, models.ForeignKey) or not field.null: + continue + target_id = getattr(instance, field.attname) + if target_id is None: + continue + target_ct = ContentType.objects.get_for_model(field.remote_field.model, for_concrete_model=True) + if (target_ct.id, str(target_id)) in demo_keys: + updates[field.attname] = None + if not updates: + return False + type(instance)._base_manager.filter(pk=instance.pk).update(**updates) + return True + + +def _delete_record(record: DemoRecord, demo_keys: set[tuple[int, str]]) -> bool: + """Удаляет запись реестра и её объект. ``False`` — объект защищён + (PROTECT) ещё живой записью; попытка повторяется позже.""" + model = record.content_type.model_class() + if model is None: + record.delete() + return True + instance = model._base_manager.filter(pk=record.object_id).first() + if instance is None: + record.delete() + return True + for attempt in range(2): + try: + with transaction.atomic(): + _delete_files(instance) + instance.delete() + break + except ProtectedError: + if attempt == 1 or not _detach_demo_references(instance, demo_keys): + return False + instance = model._base_manager.get(pk=record.object_id) + record.delete() + return True + + +def remove_records(dataset: DemoDataset) -> int: + """Удаляет все записи дата-сета в обратном порядке создания. + + Обратный порядок снимает почти все зависимости; оставшиеся защищённые + связи (например, статья портала ↔ её опубликованная ревизия) разрешаются + повторными проходами: после удаления родителя каскад забирает зависимую + запись, и на следующем проходе она уже отсутствует. + """ + removed = 0 + pending = list(dataset.records.select_related("content_type").order_by("-sequence")) + demo_keys = {(record.content_type_id, record.object_id) for record in pending} + while pending: + deferred: list[DemoRecord] = [] + for record in pending: + existed = record.content_type.model_class() is not None and ( + record.content_type.model_class()._base_manager.filter(pk=record.object_id).exists() + ) + if _delete_record(record, demo_keys): + removed += int(existed) + else: + deferred.append(record) + if len(deferred) == len(pending): + blocked = ", ".join(f"{r.content_type.model}:{r.object_id}" for r in deferred[:5]) + raise ProtectedError(f"Demo records cannot be deleted: {blocked}", set()) + pending = deferred + return removed diff --git a/apps/backend/hub_platform/identity/demo_seed/service.py b/apps/backend/hub_platform/identity/demo_seed/service.py new file mode 100644 index 0000000..616991f --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/service.py @@ -0,0 +1,204 @@ +"""Установка и удаление демо-данных организации. + +Запрос (из мастера первого запуска или из «Настроек») фиксирует состояние +``DemoDataset`` и ставит outbox-событие; работу выполняет worker +(``identity.event_handlers``), чтобы HTTP-запрос не ждал минуты индексации +знаний и раскладки файлов. Карточка в настройках опрашивает ``demo_status``. +""" + +from __future__ import annotations + +import logging + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone + +from hub_platform.events.services import DomainEvent, enqueue_event +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.demo_models import DemoDataset, DemoDatasetStatus +from hub_platform.identity.demo_seed.orchestrator import run_demo_seed +from hub_platform.identity.demo_seed.registry import recording, remove_records +from hub_platform.identity.models import HumanUser, Organization +from hub_platform.tenancy.context import TenantContext + +logger = logging.getLogger(__name__) + +from hub_platform.identity.event_handlers import ( # noqa: E402 + DEMO_INSTALL_REQUESTED, + DEMO_REMOVE_REQUESTED, +) + + +class DemoBusy(ValidationError): + """Установка или удаление уже идут.""" + + +def showcase_accounts() -> list[dict[str, object]]: + """Учётки для входа «посмотреть глазами сотрудника»: админ и по одному + сотруднику из разных групп. Пароль общий и намеренно публичный — это демо.""" + from hub_platform.identity.demo_seed import manifest + + data = manifest.load("organization") + by_key = {item["key"]: item for item in data["accounts"]} + groups = {item["key"]: item["name"] for item in data.get("groups", [])} + accounts = [] + for key in data.get("showcaseAccounts", []): + item = by_key[key] + accounts.append( + { + "fullName": item["fullName"], + "email": item["email"], + "password": data["demoPassword"], + "role": item["role"], + "positionTitle": item.get("positionTitle", ""), + "groups": [groups[g] for g in item.get("groups", []) if g in groups], + } + ) + return accounts + + +def demo_status(organization: Organization) -> dict[str, object]: + dataset = DemoDataset.objects.filter(organization=organization).first() + if dataset is None: + return { + "status": "ABSENT", + "recordsCount": 0, + "error": "", + "startedAt": None, + "finishedAt": None, + "accounts": [], + } + installed = dataset.status == DemoDatasetStatus.INSTALLED + return { + "status": dataset.status, + "recordsCount": dataset.records_count, + "error": dataset.error, + "startedAt": dataset.started_at.isoformat(), + "finishedAt": dataset.finished_at.isoformat() if dataset.finished_at else None, + "accounts": showcase_accounts() if installed else [], + } + + +def _busy(dataset: DemoDataset | None) -> bool: + return dataset is not None and dataset.status in ( + DemoDatasetStatus.INSTALLING, + DemoDatasetStatus.REMOVING, + ) + + +def request_install(*, context: TenantContext, actor: HumanUser | None) -> DemoDataset: + """Ставит установку в очередь. Повторный запрос при установленном наборе — + ошибка: сначала удаление.""" + with transaction.atomic(): + dataset = ( + DemoDataset.objects.select_for_update() + .filter(organization_id=context.organization_id) + .first() + ) + if _busy(dataset): + raise DemoBusy({"demo": "Демо-данные сейчас устанавливаются или удаляются"}) + if dataset is not None and dataset.status == DemoDatasetStatus.INSTALLED: + raise ValidationError({"demo": "Демо-данные уже установлены"}) + if dataset is None: + dataset = DemoDataset(organization_id=context.organization_id) + dataset.status = DemoDatasetStatus.INSTALLING + dataset.requested_by = actor + dataset.error = "" + dataset.finished_at = None + dataset.started_at = timezone.now() + dataset.save() + record_audit_event( + organization=context.organization, + actor=actor, + action="demo.install_requested", + object_type="DemoDataset", + object_id=str(dataset.id), + ) + enqueue_event( + DomainEvent( + aggregate_type="DemoDataset", + aggregate_id=str(dataset.id), + event_type=DEMO_INSTALL_REQUESTED, + payload={"datasetId": dataset.id}, + tenant_context=context, + ) + ) + return dataset + + +def request_remove(*, context: TenantContext, actor: HumanUser | None) -> DemoDataset: + with transaction.atomic(): + dataset = ( + DemoDataset.objects.select_for_update() + .filter(organization_id=context.organization_id) + .first() + ) + if dataset is None: + raise ValidationError({"demo": "Демо-данные не установлены"}) + if _busy(dataset): + raise DemoBusy({"demo": "Демо-данные сейчас устанавливаются или удаляются"}) + dataset.status = DemoDatasetStatus.REMOVING + dataset.requested_by = actor + dataset.error = "" + dataset.finished_at = None + dataset.save(update_fields=["status", "requested_by", "error", "finished_at"]) + record_audit_event( + organization=context.organization, + actor=actor, + action="demo.remove_requested", + object_type="DemoDataset", + object_id=str(dataset.id), + ) + enqueue_event( + DomainEvent( + aggregate_type="DemoDataset", + aggregate_id=str(dataset.id), + event_type=DEMO_REMOVE_REQUESTED, + payload={"datasetId": dataset.id}, + tenant_context=context, + ) + ) + return dataset + + +def install(*, context: TenantContext, dataset: DemoDataset) -> DemoDataset: + """Выполняет сид под записью в реестр. Вызывается внутри tenant_atomic.""" + try: + # Savepoint: при сбое откатываются и данные, и их записи в реестре — + # набор либо полный, либо отсутствует. + with transaction.atomic(), recording(dataset) as recorder: + run_demo_seed(context, recorder=recorder) + dataset.records_count = dataset.records.count() + dataset.status = DemoDatasetStatus.INSTALLED + dataset.error = "" + except Exception as error: # noqa: BLE001 — статус ошибки должен дойти до UI + logger.exception("Demo install failed for organization %s", context.organization_id) + dataset.records_count = 0 + dataset.status = DemoDatasetStatus.FAILED + dataset.error = str(error)[:2000] + dataset.finished_at = timezone.now() + dataset.save(update_fields=["records_count", "status", "error", "finished_at"]) + record_audit_event( + organization=context.organization, + actor=dataset.requested_by, + action="demo.installed" if dataset.status == DemoDatasetStatus.INSTALLED else "demo.install_failed", + object_type="DemoDataset", + object_id=str(dataset.id), + payload={"recordsCount": dataset.records_count}, + ) + return dataset + + +def remove(*, context: TenantContext, dataset: DemoDataset) -> None: + """Удаляет записи по реестру и сам дата-сет. Вызывается внутри tenant_atomic.""" + removed = remove_records(dataset) + record_audit_event( + organization=context.organization, + actor=dataset.requested_by, + action="demo.removed", + object_type="DemoDataset", + object_id=str(dataset.id), + payload={"recordsCount": removed}, + ) + dataset.delete() diff --git a/apps/backend/hub_platform/identity/demo_views.py b/apps/backend/hub_platform/identity/demo_views.py new file mode 100644 index 0000000..daf7e83 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_views.py @@ -0,0 +1,75 @@ +"""Демо-данные организации: статус, установка, удаление («Настройки»).""" + +import mimetypes + +from django.core.exceptions import ValidationError +from django.http import FileResponse, Http404 +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.api.permissions import HasCapability +from hub_platform.identity.demo_seed import manifest, service + + +def _error(error: ValidationError, status: int) -> Response: + if hasattr(error, "message_dict"): + detail = "; ".join( + message for messages in error.message_dict.values() for message in messages + ) + else: + detail = "; ".join(error.messages) + return Response({"detail": detail}, status=status) + + +class DemoDataView(APIView): + permission_classes = [HasCapability] + required_capabilities = { + "GET": "settings.view", + "POST": "company.manage", + "DELETE": "company.manage", + } + require_organization_scope = True + + def get(self, request: Request) -> Response: + return Response(service.demo_status(request.tenant_context.organization)) + + def post(self, request: Request) -> Response: + try: + service.request_install(context=request.tenant_context, actor=request.user) + except service.DemoBusy as error: + return _error(error, 409) + except ValidationError as error: + return _error(error, 400) + return Response(service.demo_status(request.tenant_context.organization), status=202) + + def delete(self, request: Request) -> Response: + try: + service.request_remove(context=request.tenant_context, actor=request.user) + except service.DemoBusy as error: + return _error(error, 409) + except ValidationError as error: + return _error(error, 400) + return Response(service.demo_status(request.tenant_context.organization), status=202) + + +class DemoMediaView(APIView): + """Публичные медиа демо-набора (аватары контактов) из пакета бэкенда. + + Отдаёт только файлы каталога demo_seed/data/media/avatars; путь + нормализуется, выход за каталог невозможен. + """ + + authentication_classes: list = [] + permission_classes = [AllowAny] + + def get(self, request: Request, name: str): + root = (manifest.MEDIA_DIR / "avatars").resolve() + candidate = (root / name).resolve() + if root not in candidate.parents or not candidate.is_file(): + raise Http404("Demo media not found") + content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream" + response = FileResponse(candidate.open("rb"), content_type=content_type) + response["Cache-Control"] = "public, max-age=86400" + return response diff --git a/apps/backend/hub_platform/identity/event_handlers.py b/apps/backend/hub_platform/identity/event_handlers.py index e55a3ef..b4d9c62 100644 --- a/apps/backend/hub_platform/identity/event_handlers.py +++ b/apps/backend/hub_platform/identity/event_handlers.py @@ -37,3 +37,43 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None user = HumanUser.objects.filter(pk=payload.get("userId"), is_active=True).first() if user is not None: send_password_reset_email(user) + + +# --- Демо-данные (мастер первого запуска и «Настройки») ----------------------- + +DEMO_INSTALL_REQUESTED = "demo.install_requested" +DEMO_REMOVE_REQUESTED = "demo.remove_requested" + + +def _demo_dataset(payload: dict, context: TenantContext | None): + from hub_platform.identity.demo_models import DemoDataset + + if context is None: + raise ValueError("Demo dataset events are tenant events") + return ( + DemoDataset.objects.select_for_update() + .filter(pk=payload.get("datasetId"), organization_id=context.organization_id) + .first() + ) + + +@register(DEMO_INSTALL_REQUESTED) +def handle_demo_install_requested(payload: dict, context: TenantContext | None) -> None: + from hub_platform.identity.demo_models import DemoDatasetStatus + from hub_platform.identity.demo_seed import service + + dataset = _demo_dataset(payload, context) + if dataset is None or dataset.status != DemoDatasetStatus.INSTALLING: + return + service.install(context=context, dataset=dataset) + + +@register(DEMO_REMOVE_REQUESTED) +def handle_demo_remove_requested(payload: dict, context: TenantContext | None) -> None: + from hub_platform.identity.demo_models import DemoDatasetStatus + from hub_platform.identity.demo_seed import service + + dataset = _demo_dataset(payload, context) + if dataset is None or dataset.status != DemoDatasetStatus.REMOVING: + return + service.remove(context=context, dataset=dataset) diff --git a/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py b/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py deleted file mode 100644 index 1353e20..0000000 --- a/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from hub_platform.identity.bootstrap import bootstrap_edevs_owner - - -class Command(BaseCommand): - help = "Bootstraps the Edevs organization and first OWNER account." - - def add_arguments(self, parser) -> None: - parser.add_argument("--email", required=True) - parser.add_argument("--password", required=True) - parser.add_argument("--name", default="") - - def handle(self, *args: object, **options: object) -> None: - if len(options["password"]) < 12: - raise CommandError("Password must contain at least 12 characters.") - result = bootstrap_edevs_owner( - email=options["email"], - password=options["password"], - full_name=options["name"], - ) - state = "created" if result.created_owner else "already_exists" - self.stdout.write( - self.style.SUCCESS( - f"OWNER {state}: {result.owner.email}; org={result.organization.slug}; groups={result.operators_group.name},{result.support_group.name}" - ) - ) diff --git a/apps/backend/hub_platform/identity/management/commands/seed_demo.py b/apps/backend/hub_platform/identity/management/commands/seed_demo.py index 6c7c442..d8a4a5a 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_demo.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_demo.py @@ -1,90 +1,69 @@ -"""Загружает полный набор демонстрационных данных организации «Северная Верфь». +"""Демо-данные «Ателье Норд» из командной строки (для разработки и CI). -Данные — выдуманные (РФ-наполнение), полностью покрывают значимые домены -системы. Источник — редактируемые JSON-манифесты в -``hub_platform/identity/demo_seed/data/``. - -Идемпотентен: повторный запуск не дублирует и не затирает записи. По умолчанию -работает в режиме dry-run (ничего не пишет); запуск с ``--apply`` применяет сид. -Вне режима отладки требует явного ``--force`` (например, при установке облака). +Штатный путь — мастер первого запуска и карточка «Демо-данные» в «Настройках». +Команда делает то же самое синхронно: ``--apply`` ставит набор в организацию +по slug, ``--remove`` удаляет его по реестру. Без флагов — сухой прогон. """ 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.identity.demo_models import DemoDataset, DemoDatasetStatus +from hub_platform.identity.demo_seed import service +from hub_platform.identity.models import Organization 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." + help = "Installs or removes the «Ателье Норд» demo dataset for an organization." 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).", - ) + parser.add_argument("--organization", required=True, help="Organization slug") + group = parser.add_mutually_exclusive_group() + group.add_argument("--apply", action="store_true", help="Install the demo dataset") + group.add_argument("--remove", action="store_true", help="Remove the installed demo dataset") 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." - ) + try: + organization = Organization.objects.get(slug=options["organization"]) + except Organization.DoesNotExist as error: + raise CommandError(f"Organization {options['organization']!r} not found") from error + context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM) - org_data = manifest.load("organization")["organization"] - - if not options["apply"]: + if not options["apply"] and not options["remove"]: + status = service.demo_status(organization) 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." + f"Dry-run: demo dataset for «{organization.name}» is {status['status']} " + f"({status['recordsCount']} records). Pass --apply or --remove." ) ) 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 + dataset = DemoDataset.objects.select_for_update().filter(organization=organization).first() + if options["apply"]: + if dataset is None: + dataset = DemoDataset.objects.create( + organization=organization, status=DemoDatasetStatus.INSTALLING + ) + elif dataset.status == DemoDatasetStatus.INSTALLED: + raise CommandError("Demo dataset is already installed; remove it first") + else: + dataset.status = DemoDatasetStatus.INSTALLING + dataset.save(update_fields=["status"]) + dataset = service.install(context=context, dataset=dataset) + if dataset.status != DemoDatasetStatus.INSTALLED: + raise CommandError(f"Demo install failed: {dataset.error}") + self.stdout.write( + self.style.SUCCESS( + f"Demo installed for «{organization.name}»: {dataset.records_count} records" + ) + ) + else: + if dataset is None: + raise CommandError("Demo dataset is not installed") + service.remove(context=context, dataset=dataset) + self.stdout.write(self.style.SUCCESS(f"Demo removed for «{organization.name}»")) diff --git a/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py b/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py new file mode 100644 index 0000000..3079535 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.15 on 2026-09-05 12:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('identity', '0021_humanuser_ui_accent_humanuser_ui_theme'), + ] + + operations = [ + migrations.CreateModel( + name='DemoDataset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('INSTALLING', 'Устанавливается'), ('INSTALLED', 'Установлены'), ('REMOVING', 'Удаляются'), ('FAILED', 'Ошибка')], default='INSTALLING', max_length=16)), + ('records_count', models.PositiveIntegerField(default=0)), + ('error', models.TextField(blank=True, default='')), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='demo_dataset', to='identity.organization')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'identity_demodataset', + }, + ), + migrations.CreateModel( + name='DemoRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.CharField(max_length=64)), + ('sequence', models.PositiveBigIntegerField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='identity.demodataset')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='identity.organization')), + ], + options={ + 'db_table': 'identity_demorecord', + 'indexes': [models.Index(fields=['dataset', 'sequence'], name='identity_de_dataset_33366a_idx')], + 'constraints': [models.UniqueConstraint(fields=('dataset', 'content_type', 'object_id'), name='identity_demorecord_unique')], + }, + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index fad815c..19aa2e0 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -239,3 +239,7 @@ from hub_platform.identity.group_models import ( # noqa: E402, F401 from hub_platform.identity.invitation_models import ( # noqa: E402, F401 OrganizationInvitation, ) +from hub_platform.identity.demo_models import ( # noqa: E402, F401 + DemoDataset, + DemoRecord, +) diff --git a/apps/backend/hub_platform/identity/setup.py b/apps/backend/hub_platform/identity/setup.py new file mode 100644 index 0000000..730cc26 --- /dev/null +++ b/apps/backend/hub_platform/identity/setup.py @@ -0,0 +1,166 @@ +"""Мастер первого запуска (SPEC-HUB-0031, open source: «развернуть за минуту»). + +Пока в инстансе нет ни одной организации, публичный эндпоинт /api/v1/setup/ +принимает одну форму: название организации, имя, e-mail и пароль владельца. +Никаких параметров в .env и CLI: всё задаёт человек в браузере. После +создания владельца мастер закрывается навсегда (409). + +Запись идёт на соединении ``platform`` — единственной runtime-роли с правом +создавать организации (SPEC-HUB-0021 §10); RLS-контекст и транзакция живут +на том же соединении. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db import connections, transaction +from django.utils.text import slugify + +from hub_platform.ai.knowledge_categories import ensure_uncategorized_category +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, + OrganizationStatus, +) +from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.routing import use_database + +ORGANIZATION_NAME_MAX_LENGTH = 255 +FULL_NAME_MAX_LENGTH = 255 + +# Алиас соединения для операций уровня инстанса. В тестах оба алиаса — +# зеркала одной тестовой БД под ролью-владельцем кластера. +INSTANCE_DB_ALIAS = "default" if settings.TESTING else "platform" + + +class SetupAlreadyCompleted(Exception): + """Организация уже есть — мастер закрыт.""" + + +@dataclass(frozen=True) +class SetupInput: + organization_name: str + full_name: str + email: str + password: str + install_demo: bool = False + + +@dataclass(frozen=True) +class SetupResult: + organization: Organization + owner: HumanUser + + +def instance_needs_setup() -> bool: + """Мастер нужен, пока не создана ни одна организация. + + Организации видны роли app целиком (RLS SELECT USING true), поэтому + проверка не требует tenant-контекста. + """ + with use_database(INSTANCE_DB_ALIAS): + return not Organization.objects.exists() + + +def _clean(data: SetupInput) -> SetupInput: + errors: dict[str, str] = {} + organization_name = data.organization_name.strip() + if not organization_name: + errors["organizationName"] = "Укажите название организации" + elif len(organization_name) > ORGANIZATION_NAME_MAX_LENGTH: + errors["organizationName"] = "Название длиннее 255 символов" + full_name = " ".join(data.full_name.split()) + if not full_name: + errors["fullName"] = "Укажите ваше имя" + elif len(full_name) > FULL_NAME_MAX_LENGTH: + errors["fullName"] = "Имя длиннее 255 символов" + email = data.email.strip() + try: + validate_email(email) + except ValidationError: + errors["email"] = "Укажите корректный e-mail" + if errors: + raise ValidationError(errors) + return SetupInput( + organization_name=organization_name, + full_name=full_name, + email=HumanUser.objects.normalize_email(email).lower(), + password=data.password, + install_demo=data.install_demo, + ) + + +def _unique_slug(name: str) -> str: + base = slugify(name)[:40].strip("-") or "organization" + candidate = base + suffix = 2 + while Organization.objects.filter(slug=candidate).exists(): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def complete_setup(data: SetupInput) -> SetupResult: + clean = _clean(data) + with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS): + # Блокировка от гонки двух вкладок: второй запрос дождётся первого и + # увидит созданную организацию. + with connections[INSTANCE_DB_ALIAS].cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatbolls.instance_setup'))") + if Organization.objects.exists(): + raise SetupAlreadyCompleted() + + # Пароль проверяется против атрибутов будущего пользователя (схожесть + # с e-mail и именем) до записи чего-либо. + probe = HumanUser(email=clean.email, full_name=clean.full_name) + validate_password(clean.password, user=probe) + + organization = Organization.objects.create( + name=clean.organization_name, + slug=_unique_slug(clean.organization_name), + status=OrganizationStatus.ACTIVE, + ) + with tenant_atomic(organization.id, using=INSTANCE_DB_ALIAS): + ensure_uncategorized_category(organization) + owner = HumanUser.objects.create_user( + email=clean.email, + password=clean.password, + full_name=clean.full_name, + is_staff=True, + is_superuser=True, + ) + OrganizationMembership.objects.create( + user=owner, + organization=organization, + role=EmployeeRole.OWNER, + position_title="Владелец", + totp_required=False, + ) + record_audit_event( + organization=organization, + actor=owner, + action="identity.instance_setup_completed", + object_type="Organization", + object_id=str(organization.public_id), + payload={"organizationName": organization.name, "installDemo": clean.install_demo}, + ) + if clean.install_demo: + # Демо ставит worker по outbox-событию; форма не ждёт. + from hub_platform.identity.demo_seed.service import request_install + + request_install( + context=TenantContext.for_resource( + organization, actor_kind=TenantActorKind.SYSTEM, actor_user=owner + ), + actor=owner, + ) + return SetupResult(organization=organization, owner=owner) diff --git a/apps/backend/hub_platform/identity/setup_urls.py b/apps/backend/hub_platform/identity/setup_urls.py new file mode 100644 index 0000000..8434045 --- /dev/null +++ b/apps/backend/hub_platform/identity/setup_urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from hub_platform.identity import setup_views + +urlpatterns = [ + path("", setup_views.SetupStatusView.as_view(), name="setup-status"), + path("complete/", setup_views.SetupView.as_view(), name="setup-complete"), +] diff --git a/apps/backend/hub_platform/identity/setup_views.py b/apps/backend/hub_platform/identity/setup_views.py new file mode 100644 index 0000000..4b1d9e8 --- /dev/null +++ b/apps/backend/hub_platform/identity/setup_views.py @@ -0,0 +1,75 @@ +from django.contrib.auth import login +from django.core.exceptions import ValidationError +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from hub_platform.identity.auth.common import _user_payload +from hub_platform.identity.setup import ( + SetupAlreadyCompleted, + SetupInput, + complete_setup, + instance_needs_setup, +) + +SETUP_CLOSED = {"detail": "Первый запуск уже выполнен: организация создана"} + + +def _validation_response(error: ValidationError) -> Response: + if hasattr(error, "message_dict"): + errors = { + key: messages[0] if isinstance(messages, list) else str(messages) + for key, messages in error.message_dict.items() + } + # validate_password кладёт сообщения без ключа поля. + if "__all__" in errors: + errors["password"] = " ".join(error.message_dict["__all__"]) + del errors["__all__"] + detail = next(iter(errors.values()), "Проверьте заполненные поля") + return Response({"detail": detail, "errors": errors}, status=400) + message = " ".join(error.messages) + return Response({"detail": message, "errors": {"password": message}}, status=400) + + +@method_decorator(ensure_csrf_cookie, name="dispatch") +class SetupStatusView(APIView): + authentication_classes: list = [] + permission_classes = [AllowAny] + + def get(self, request: Request) -> Response: + return Response({"needsSetup": instance_needs_setup()}) + + +@method_decorator(csrf_protect, name="dispatch") +class SetupView(APIView): + authentication_classes: list = [] + permission_classes = [AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "login" + + def post(self, request: Request) -> Response: + body = request.data if isinstance(request.data, dict) else {} + if not instance_needs_setup(): + return Response(SETUP_CLOSED, status=409) + try: + result = complete_setup( + SetupInput( + organization_name=str(body.get("organizationName", "")), + full_name=str(body.get("fullName", "")), + email=str(body.get("email", "")), + password=str(body.get("password", "")), + install_demo=bool(body.get("installDemo", False)), + ) + ) + except SetupAlreadyCompleted: + return Response(SETUP_CLOSED, status=409) + except ValidationError as error: + return _validation_response(error) + owner = result.owner + owner.backend = "django.contrib.auth.backends.ModelBackend" + login(request, owner) + return Response({"authenticated": True, "user": _user_payload(owner)}, status=201) diff --git a/apps/backend/hub_platform/identity/test_seed_demo.py b/apps/backend/hub_platform/identity/test_seed_demo.py index 8ee4ca5..45dbd17 100644 --- a/apps/backend/hub_platform/identity/test_seed_demo.py +++ b/apps/backend/hub_platform/identity/test_seed_demo.py @@ -1,93 +1,225 @@ -from io import StringIO +"""Демо-набор «Ателье Норд»: полное покрытие моделей, точное удаление, API.""" -from django.core.management import call_command +from __future__ import annotations + +import json +import tempfile + +from django.apps import apps +from django.db import models from django.test import TestCase, override_settings +from hub_platform.testing import TenantAPIClient as APIClient -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.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment +from hub_platform.conversations.models import ( + Contact, + Conversation, + LifecycleState, + Message, + MessageKind, ) -from hub_platform.notifications.models import Notification -from hub_platform.products.models import Product -from hub_platform.support.models import ProductSupportContract, SupportIdentitySnapshot -from hub_platform.support_portals.models import PortalArticle, SupportPortal +from hub_platform.events.handlers import dispatch +from hub_platform.events.models import OutboxEvent +from hub_platform.identity.demo_models import DemoDataset, DemoDatasetStatus, DemoRecord +from hub_platform.identity.demo_seed import service +from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership +from hub_platform.identity.setup import SetupInput, complete_setup from hub_platform.tenancy.context import TenantActorKind, TenantContext from hub_platform.tenancy.database import tenant_atomic -DEMO_SLUG = "severnaya-verf" +_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-demo-media-") + +OWNER = { + "organization_name": "Моё ателье", + "full_name": "Елена Кузнецова", + "email": "owner@atelie.test", + "password": "Owner-Setup-2026!", +} + +# Модели, которые демо-набор не обязан заполнять: сам реестр, организация +# установщика (уже есть), платформенный контур и очередь событий. +COVERAGE_EXEMPT = { + ("identity", "organization"), + ("identity", "demodataset"), + ("identity", "demorecord"), +} +COVERAGE_EXEMPT_APPS = {"platform", "events"} -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) +def _covered_models() -> list[type[models.Model]]: + result = [] + for model in apps.get_models(): + label = model._meta.app_label + if not model.__module__.startswith("hub_platform."): + continue + if label in COVERAGE_EXEMPT_APPS or (label, model._meta.model_name) in COVERAGE_EXEMPT: + continue + result.append(model) + return result -class SeedDemoTests(TestCase): - """Покрывает создание, идемпотентность и dry-run демо-сида «Северная Верфь».""" +def _counts() -> dict[str, int]: + return {m._meta.label: m._base_manager.count() for m in _covered_models()} + + +@override_settings(MEDIA_ROOT=_MEDIA_ROOT) +class DemoDatasetTests(TestCase): + def setUp(self) -> None: + result = complete_setup(SetupInput(**OWNER)) + self.organization: Organization = result.organization + self.owner: HumanUser = result.owner + self.context = TenantContext.for_resource( + self.organization, actor_kind=TenantActorKind.SYSTEM, actor_user=self.owner + ) + + def _install(self) -> DemoDataset: + with tenant_atomic(self.context): + dataset = DemoDataset.objects.create( + organization=self.organization, status=DemoDatasetStatus.INSTALLING + ) + return service.install(context=self.context, dataset=dataset) + + def test_install_covers_every_model_of_the_system(self) -> None: + before = _counts() + dataset = self._install() + + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLED, dataset.error) + after = _counts() + missing = sorted(label for label, count in after.items() if count <= before[label]) + self.assertEqual(missing, [], f"demo dataset leaves models empty: {missing}") + self.assertGreater(dataset.records_count, 200) + + def test_dataset_matches_the_design_baseline(self) -> None: + self._install() + organization = self.organization + + # Люди: владелец установщика + 6 демо-сотрудников, один заблокирован. + memberships = OrganizationMembership.objects.filter(organization=organization) + self.assertEqual(memberships.count(), 7) + self.assertEqual(memberships.filter(blocked_at__isnull=False).count(), 1) + anna = HumanUser.objects.get(email="a.kim@atelie-nord.ru") + self.assertTrue(anna.check_password("Chatbolls-Demo-2026")) + self.assertTrue(HumanUser.objects.get(email="i.saveliev@atelie-nord.ru").totp_enabled) + + # Агенты: активный с AI, черновик без AI, выключенный канал. + agents = AIAgent.objects.filter(channel__organization=organization) + self.assertEqual(agents.filter(status=AIAgentStatus.ACTIVE).count(), 2) + self.assertEqual(agents.filter(status=AIAgentStatus.DRAFT).count(), 1) + self.assertEqual(organization.channels.filter(is_active=False).count(), 1) + + # Знания: категории двух уровней, вложения, отключённое знание. + self.assertGreaterEqual(KnowledgeAttachment.objects.filter(organization=organization).count(), 3) + self.assertEqual(Knowledge.objects.filter(organization=organization, is_enabled=False).count(), 1) + + # Диалоги во всех состояниях (кадры A–H). + conversations = Conversation.objects.filter(organization=organization) + self.assertEqual(conversations.filter(lifecycle=LifecycleState.SPAM).count(), 1) + self.assertGreaterEqual(conversations.filter(lifecycle=LifecycleState.CLOSED).count(), 3) + self.assertEqual(conversations.filter(archived_at__isnull=False).count(), 1) + self.assertGreaterEqual(conversations.filter(control_mode="PAUSED", lifecycle="OPEN").count(), 3) + self.assertTrue(conversations.filter(assigned_operator=anna).exists()) + self.assertTrue(conversations.filter(previous_conversation__isnull=False).exists()) + self.assertTrue(conversations.filter(labels__name="Срочно").exists()) + # История контакта: у Марии два диалога. + maria = Contact.objects.get(organization=organization, name="Мария Соколова") + self.assertEqual(conversations.filter(contact=maria).count(), 2) + self.assertTrue(maria.avatar_url.startswith("/api/v1/demo-media/avatars/")) + # Веб-гость получил настоящую сессию виджета. + self.assertTrue(conversations.filter(contact__name__startswith="Веб-гость").exists()) + # Сообщения: контакт-шаринг и системные события есть; голосовые — при наличии файлов. + messages = Message.objects.filter(conversation__organization=organization) + self.assertTrue(messages.filter(kind=MessageKind.CONTACT).exists()) + self.assertTrue(messages.filter(author_type="SYSTEM").exists()) + + def test_remove_restores_the_exact_state_before_install(self) -> None: + before = _counts() + dataset = self._install() + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLED, dataset.error) + + with tenant_atomic(self.context): + service.remove(context=self.context, dataset=DemoDataset.objects.get(pk=dataset.pk)) + + after = _counts() + # Аудит установки и удаления — единственный след администраторских действий. + after["identity.AuditEvent"] = before["identity.AuditEvent"] + self.assertEqual(after, before) + self.assertFalse(DemoDataset.objects.exists()) + self.assertFalse(DemoRecord.objects.exists()) + # Организация и владелец установщика на месте. + self.assertTrue(Organization.objects.filter(pk=self.organization.pk).exists()) + self.assertTrue(HumanUser.objects.filter(pk=self.owner.pk).exists()) + + +@override_settings(MEDIA_ROOT=_MEDIA_ROOT) +class DemoDatasetApiTests(TestCase): + """Карточка «Демо-данные»: очередь через outbox, worker выполняет.""" def setUp(self) -> None: - # Организация создаётся командой; здесь нужен только shell для контекста. - Organization.objects.create(name="Северная Верфь", slug=DEMO_SLUG) + complete_setup(SetupInput(**OWNER)) + self.organization = Organization.objects.get() + self.client = APIClient() + self.client.login(username=OWNER["email"], password=OWNER["password"]) - def test_creates_full_dataset(self) -> None: - refs = _run_seed() + def _dispatch_pending(self) -> None: + for event in OutboxEvent.objects.filter(event_type__startswith="demo.").order_by("id"): + dispatch(event) + event.delete() - 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 + def test_install_and_remove_through_api_and_worker(self) -> None: + self.assertEqual(self.client.get("/api/v1/company/demo/").json()["status"], "ABSENT") + + queued = self.client.post("/api/v1/company/demo/") + self.assertEqual(queued.status_code, 202) + self.assertEqual(queued.json()["status"], "INSTALLING") + # Повторный запрос во время установки отклоняется. + self.assertEqual(self.client.post("/api/v1/company/demo/").status_code, 409) + + self._dispatch_pending() + installed = self.client.get("/api/v1/company/demo/").json() + self.assertEqual(installed["status"], "INSTALLED", installed["error"]) + self.assertGreater(installed["recordsCount"], 200) + # Витринные учётки: админ и сотрудники из разных групп, с паролем. + accounts = installed["accounts"] + self.assertEqual([a["email"] for a in accounts], ["a.kim@atelie-nord.ru", "d.orlov@atelie-nord.ru", "a.gusev@atelie-nord.ru"]) + self.assertEqual(accounts[1]["groups"], ["Консультанты"]) + self.assertEqual(accounts[2]["groups"], ["Поддержка"]) + self.assertTrue(all(a["password"] == "Chatbolls-Demo-2026" for a in accounts)) + self.assertEqual(self.client.post("/api/v1/company/demo/").status_code, 400) + + removing = self.client.delete("/api/v1/company/demo/") + self.assertEqual(removing.status_code, 202) + self.assertEqual(removing.json()["status"], "REMOVING") + self._dispatch_pending() + self.assertEqual(self.client.get("/api/v1/company/demo/").json()["status"], "ABSENT") + self.assertFalse(HumanUser.objects.filter(email="a.kim@atelie-nord.ru").exists()) + + def test_employee_cannot_manage_demo(self) -> None: + self.client.post("/api/v1/company/demo/") + self._dispatch_pending() + employee = APIClient() + employee.login(username="d.orlov@atelie-nord.ru", password="Chatbolls-Demo-2026") + self.assertEqual(employee.post("/api/v1/company/demo/").status_code, 403) + self.assertEqual(employee.delete("/api/v1/company/demo/").status_code, 403) + + + +class SetupWizardDemoTests(TestCase): + def test_setup_wizard_queues_demo_install(self) -> None: + # Пустой инстанс: мастер с флагом installDemo ставит набор в очередь. + response = APIClient().post( + "/api/v1/setup/complete/", + data=json.dumps( + { + "organizationName": "Ателье Норд", + "fullName": "Елена Кузнецова", + "email": "e.kuznetsova@atelie-nord.ru", + "password": "Nord-Atelier-2026!", + "installDemo": True, + } + ), + content_type="application/json", ) - # Продукты. - self.assertEqual(Product.objects.filter(organization=refs.organization).count(), 2) - # Каналы и 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(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(), - } - _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(), - } - 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()) + self.assertEqual(response.status_code, 201, response.content) + dataset = DemoDataset.objects.get() + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLING) + self.assertTrue(OutboxEvent.objects.filter(event_type="demo.install_requested").exists()) diff --git a/apps/backend/hub_platform/identity/test_setup.py b/apps/backend/hub_platform/identity/test_setup.py new file mode 100644 index 0000000..089086f --- /dev/null +++ b/apps/backend/hub_platform/identity/test_setup.py @@ -0,0 +1,103 @@ +import json + +from django.test import TestCase +from hub_platform.testing import TenantAPIClient as APIClient + +from hub_platform.ai.models import KnowledgeCategory +from hub_platform.identity.models import ( + AuditEvent, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) + +VALID = { + "organizationName": "Ателье Норд", + "fullName": "Елена Кузнецова", + "email": "E.Kuznetsova@atelie-nord.ru", + "password": "Nord-Atelier-2026!", +} + + +class SetupWizardTests(TestCase): + """Мастер первого запуска: одна форма в браузере, никаких параметров в .env.""" + + def setUp(self) -> None: + self.client = APIClient() + + def complete(self, **overrides): + body = {**VALID, **overrides} + return self.client.post( + "/api/v1/setup/complete/", data=json.dumps(body), content_type="application/json" + ) + + def test_fresh_instance_needs_setup(self) -> None: + response = self.client.get("/api/v1/setup/") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["needsSetup"]) + + def test_complete_creates_organization_owner_and_logs_in(self) -> None: + response = self.complete() + + self.assertEqual(response.status_code, 201, response.content) + payload = response.json() + self.assertTrue(payload["authenticated"]) + user = payload["user"] + self.assertEqual(user["email"], "e.kuznetsova@atelie-nord.ru") + self.assertEqual(user["fullName"], "Елена Кузнецова") + self.assertFalse(user["mustChangePassword"]) + self.assertEqual(len(user["memberships"]), 1) + self.assertEqual(user["memberships"][0]["role"], EmployeeRole.OWNER) + self.assertEqual(user["memberships"][0]["organizationName"], "Ателье Норд") + + organization = Organization.objects.get() + # Кириллическое имя не даёт slug — берётся нейтральный. + self.assertEqual(organization.slug, "organization") + owner = HumanUser.objects.get(email="e.kuznetsova@atelie-nord.ru") + self.assertTrue(owner.check_password(VALID["password"])) + self.assertTrue(owner.is_superuser) + membership = OrganizationMembership.objects.get(user=owner, organization=organization) + self.assertEqual(membership.role, EmployeeRole.OWNER) + self.assertTrue(KnowledgeCategory.objects.filter(organization=organization).exists()) + self.assertTrue( + AuditEvent.objects.filter( + organization=organization, action="identity.instance_setup_completed" + ).exists() + ) + + # Сессия установлена: приложение сразу открывается под владельцем. + session = self.client.get("/api/v1/auth/session/") + self.assertTrue(session.json()["authenticated"]) + + def test_latin_name_becomes_slug(self) -> None: + self.complete(organizationName="Nord Atelier & Co") + self.assertEqual(Organization.objects.get().slug, "nord-atelier-co") + + def test_setup_closes_after_first_owner(self) -> None: + self.assertEqual(self.complete().status_code, 201) + + self.assertFalse(self.client.get("/api/v1/setup/").json()["needsSetup"]) + second = APIClient().post( + "/api/v1/setup/complete/", + data=json.dumps({**VALID, "email": "other@atelie-nord.ru"}), + content_type="application/json", + ) + self.assertEqual(second.status_code, 409) + self.assertEqual(Organization.objects.count(), 1) + self.assertEqual(HumanUser.objects.count(), 1) + + def test_validation_errors_name_the_fields(self) -> None: + response = self.complete(organizationName=" ", fullName="", email="not-an-email") + self.assertEqual(response.status_code, 400) + self.assertEqual( + set(response.json()["errors"]), {"organizationName", "fullName", "email"} + ) + self.assertFalse(Organization.objects.exists()) + + def test_weak_password_is_rejected_before_anything_is_written(self) -> None: + response = self.complete(password="password") + self.assertEqual(response.status_code, 400) + self.assertIn("password", response.json()["errors"]) + self.assertFalse(Organization.objects.exists()) + self.assertFalse(HumanUser.objects.exists()) diff --git a/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py b/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py new file mode 100644 index 0000000..0f362a7 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py @@ -0,0 +1,49 @@ +# Реестр демо-данных (identity_demodataset, identity_demorecord): tenant-таблицы +# с organization_id, RLS и гранты по образцу tenancy/0021. Платформенная роль +# получает те же права: мастер первого запуска ставит демо-набор в очередь на +# соединении platform (SPEC-HUB-0021 §10). +from django.db import migrations + +TABLES = ("identity_demodataset", "identity_demorecord") + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO custocrm_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} + TO custocrm_runtime_app, custocrm_runtime_platform; +GRANT ALL ON {table} TO custocrm_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema; +DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; +CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app, custocrm_runtime_platform + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); +DROP POLICY IF EXISTS custocrm_schema_access ON {table}; +CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + + +def remove_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0021_chat_extras_guards"), + ("identity", "0022_demo_dataset_registry"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/backend/hub_platform/tenancy/routing.py b/apps/backend/hub_platform/tenancy/routing.py new file mode 100644 index 0000000..024d723 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/routing.py @@ -0,0 +1,45 @@ +"""Принудительный выбор соединения БД для блока кода. + +Runtime backend-app держит два соединения: ``default`` (роль app) и +``platform`` (роль platform, права на создание организаций — SPEC-HUB-0021). +Обычные запросы идут в ``default``; операции уровня инстанса (мастер первого +запуска) выполняются целиком на ``platform`` через ``use_database("platform")``, +чтобы транзакция и RLS-контекст жили на одном соединении. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +_forced_alias: ContextVar[str | None] = ContextVar("hub_forced_db_alias", default=None) + + +@contextmanager +def use_database(alias: str) -> Iterator[None]: + token = _forced_alias.set(alias) + try: + yield + finally: + _forced_alias.reset(token) + + +def forced_database() -> str | None: + return _forced_alias.get() + + +class ForcedAliasRouter: + """Django database router: внутри ``use_database`` все запросы идут в алиас.""" + + def db_for_read(self, model, **hints): # noqa: ANN001, ARG002 + return _forced_alias.get() + + def db_for_write(self, model, **hints): # noqa: ANN001, ARG002 + return _forced_alias.get() + + def allow_relation(self, obj1, obj2, **hints): # noqa: ANN001, ARG002 + return None + + def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: ANN001, ARG002 + return None diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index abd305b..45bcf3e 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -8,7 +8,7 @@ import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearan import { api, setActiveOrganization } from "./api/client"; import { canAccess, defaultRoute, isManager } from "./auth/access"; import { activateOrganization, clearOrganizationPreference } from "./auth/session"; -import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; +import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; import { Shell } from "./layout/Shell"; import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; @@ -34,6 +34,8 @@ export function App() { const [totpChallenge, setTotpChallenge] = useState(null); const [recovering, setRecovering] = useState(false); const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password"); + // Мастер первого запуска: пока в инстансе нет организации, вместо входа — форма создания. + const [needsSetup, setNeedsSetup] = useState(false); const [data, setData] = useState({ employees: [], groups: [], products: [], agents: [] }); const [dataError, setDataError] = useState(false); const navigation = useRouteNavigation(initialRoute, organizationPublicId); @@ -88,8 +90,11 @@ export function App() { ); window.history.replaceState({}, "", nextPath); } + return undefined; } + return api<{ needsSetup: boolean }>("/api/v1/setup/").then((setup) => setNeedsSetup(setup.needsSetup)); }) + .catch(() => undefined) .finally(() => setSessionLoading(false)); }, [initialRoute, useIdentity]); @@ -147,7 +152,9 @@ export function App() { {totpChallenge ? ( { setTotpChallenge(null); landAfterAuth(nextUser); }} /> ) : !identity ? ( - recovering ? ( + needsSetup ? ( + { setNeedsSetup(false); landAfterAuth(nextUser); }} /> + ) : recovering ? ( setRecovering(false)} /> ) : ( setRecovering(true)} /> diff --git a/apps/internal-ui/src/features/auth/AuthField.tsx b/apps/internal-ui/src/features/auth/AuthField.tsx index 9651778..0c730ae 100644 --- a/apps/internal-ui/src/features/auth/AuthField.tsx +++ b/apps/internal-ui/src/features/auth/AuthField.tsx @@ -14,7 +14,7 @@ export function AuthField({ }: { children?: ReactNode; error?: boolean; - icon: "lock" | "mail"; + icon: "lock" | "mail" | "building" | "user"; onChange: (value: string) => void; placeholder: string; type?: "password" | "text"; diff --git a/apps/internal-ui/src/features/auth/AuthScreens.tsx b/apps/internal-ui/src/features/auth/AuthScreens.tsx index 75ada25..bd262d0 100644 --- a/apps/internal-ui/src/features/auth/AuthScreens.tsx +++ b/apps/internal-ui/src/features/auth/AuthScreens.tsx @@ -2,5 +2,6 @@ export { AuthChangePassword } from "./AuthChangePassword"; export { AuthLogin } from "./AuthLogin"; export { AuthPasswordRecovery } from "./AuthPasswordRecovery"; export { AuthResetPassword } from "./AuthResetPassword"; +export { AuthSetup } from "./AuthSetup"; export { AuthTotpCode } from "./AuthTotpCode"; export { AuthTotpSetup } from "./AuthTotpSetup"; diff --git a/apps/internal-ui/src/features/auth/AuthSetup.tsx b/apps/internal-ui/src/features/auth/AuthSetup.tsx new file mode 100644 index 0000000..228ef10 --- /dev/null +++ b/apps/internal-ui/src/features/auth/AuthSetup.tsx @@ -0,0 +1,92 @@ +import { type FormEvent, useState } from "react"; + +import { api, ApiError } from "../../api/client"; +import type { AuthenticatedUser } from "../../types"; +import { Icon } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import { AuthField } from "./AuthField"; +import { AuthFrame } from "./AuthFrame"; +import { passwordIsValid, passwordLabels, passwordScore } from "./password"; + +type SetupErrors = Partial>; + +/** + * Мастер первого запуска: пока в инстансе нет организации, вместо входа — + * одна форма. Никаких параметров в .env: всё задаёт человек здесь. + */ +export function AuthSetup({ onDone }: { onDone: (user: AuthenticatedUser) => void }) { + const [organizationName, setOrganizationName] = useState(""); + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [show, setShow] = useState(false); + const [installDemo, setInstallDemo] = useState(true); + const [errors, setErrors] = useState({}); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const score = passwordScore(password); + const valid = organizationName.trim() !== "" && fullName.trim() !== "" && email.trim() !== "" && passwordIsValid(password, false); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!valid) return; + setSubmitting(true); + setError(""); + setErrors({}); + try { + const payload = await api<{ authenticated: true; user: AuthenticatedUser }>("/api/v1/setup/complete/", { + method: "POST", + body: JSON.stringify({ organizationName, fullName, email, password, installDemo }), + }); + onDone(payload.user); + } catch (requestError) { + if (requestError instanceof ApiError) { + const fieldErrors = (requestError.payload as { errors?: SetupErrors }).errors; + if (fieldErrors) setErrors(fieldErrors); + setError(requestError.message); + } else { + setError("Не удалось создать организацию"); + } + } finally { + setSubmitting(false); + } + } + + return ( + +
+ {error &&
!{error}
} + + + + + + + + + + +
+
{[0, 1, 2, 3].map((item) => )}
+

{passwordLabels[score]}

+
+
+ ТРЕБОВАНИЯ К ПАРОЛЮ + = 10 ? "done" : ""}>Не менее 10 символов + Содержит цифру + Буквы и спецсимвол +
+ + + +
+ ); +} diff --git a/apps/internal-ui/src/features/auth/styles.css b/apps/internal-ui/src/features/auth/styles.css index 81baaa5..2a9445c 100644 --- a/apps/internal-ui/src/features/auth/styles.css +++ b/apps/internal-ui/src/features/auth/styles.css @@ -239,6 +239,44 @@ color: var(--success-text); } +.auth-checkbox { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 20px; + padding: 13px 14px; + background: var(--n-10); + border: 1px solid var(--n-8); + border-radius: 10px; + cursor: pointer; +} + +.auth-checkbox input { + flex: none; + width: 16px; + height: 16px; + margin: 2px 0 0; + accent-color: var(--primary); +} + +.auth-checkbox span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.auth-checkbox strong { + color: var(--n-2); + font-size: 13px; + font-weight: 600; +} + +.auth-checkbox small { + color: var(--n-4); + font-size: 12px; + line-height: 1.4; +} + .auth-totp-setup-card { padding: 26px; } diff --git a/apps/internal-ui/src/features/settings/DemoDataCard.tsx b/apps/internal-ui/src/features/settings/DemoDataCard.tsx new file mode 100644 index 0000000..c93d1ee --- /dev/null +++ b/apps/internal-ui/src/features/settings/DemoDataCard.tsx @@ -0,0 +1,167 @@ +import { Modal } from "antd"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "../../api/client"; +import { Button } from "../../shared/ui-controls"; + +// Секция «Демо-данные» экрана настроек: владелец или админ ставит и удаляет +// вымышленную организацию «Ателье Норд» одной кнопкой. Работу выполняет +// worker; карточка опрашивает статус, пока идёт установка или удаление. + +export type DemoStatus = "ABSENT" | "INSTALLING" | "INSTALLED" | "REMOVING" | "FAILED"; + +export type DemoAccount = { + fullName: string; + email: string; + password: string; + role: "OWNER" | "ADMIN" | "EMPLOYEE"; + positionTitle: string; + groups: string[]; +}; + +export type DemoState = { + status: DemoStatus; + recordsCount: number; + error: string; + startedAt: string | null; + finishedAt: string | null; + accounts: DemoAccount[]; +}; + +const BUSY: DemoStatus[] = ["INSTALLING", "REMOVING"]; +const POLL_INTERVAL_MS = 2000; + +export function demoStatusLabel(state: DemoState): string { + switch (state.status) { + case "INSTALLED": + return "Установлены"; + case "INSTALLING": + return "Устанавливаются"; + case "REMOVING": + return "Удаляются"; + case "FAILED": + return "Ошибка установки"; + default: + return "Не установлены"; + } +} + +export function DemoDataCard({ reload }: { reload: () => void }) { + const [state, setState] = useState(null); + const [error, setError] = useState(""); + const [confirmRemove, setConfirmRemove] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(async () => { + try { + setState(await api("/api/v1/company/demo/")); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось получить статус демо-данных"); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const busy = state !== null && BUSY.includes(state.status); + useEffect(() => { + if (!busy) return undefined; + const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [busy, load]); + + // Когда работа завершилась — обновить данные приложения (сотрудники, агенты). + const [wasBusy, setWasBusy] = useState(false); + useEffect(() => { + if (busy) setWasBusy(true); + else if (wasBusy) { + setWasBusy(false); + reload(); + } + }, [busy, reload, wasBusy]); + + async function install() { + setSubmitting(true); + setError(""); + try { + setState(await api("/api/v1/company/demo/", { method: "POST" })); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось запустить установку"); + } finally { + setSubmitting(false); + } + } + + async function remove() { + setSubmitting(true); + setError(""); + try { + setState(await api("/api/v1/company/demo/", { method: "DELETE" })); + setConfirmRemove(false); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось запустить удаление"); + } finally { + setSubmitting(false); + } + } + + const label = state ? demoStatusLabel(state) : null; + const installed = state?.status === "INSTALLED"; + + return ( +
+
+

Демо-данные

+ {label && {label}} +
+

+ Вымышленное ателье «Норд»: сотрудники и группы, агенты с подключениями, знания, диалоги во всех состояниях, + голосовые, шаблоны ответов, портал поддержки, звонки и уведомления. Удаляются целиком — ваши данные не затрагиваются. +

+ {state?.status === "INSTALLED" && ( + <> +

Записей: {state.recordsCount}. Войдите под демо-сотрудником в другом браузере или приватном окне, чтобы увидеть систему его глазами:

+
+ {state.accounts.map((account) => ( +
+
+ {account.fullName} + {account.role === "EMPLOYEE" ? "Сотрудник" : "Администратор"} · {account.groups.length ? account.groups.join(", ") : "без групп"} +
+ {account.email} + {account.password} +
+ ))} +
+ + )} + {state?.status === "FAILED" && state.error &&
{state.error}
} + {error &&
{error}
} +
+ {!installed && ( + + )} + {(installed || state?.status === "REMOVING") && ( + + )} +
+ {confirmRemove && ( + setConfirmRemove(false)} footer={null} destroyOnClose> +
+

Будут удалены все записи демо-набора: сотрудники, агенты, диалоги, знания и файлы. Данные, созданные вами, останутся.

+ {error &&
{error}
} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index a10a454..6df8005 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -13,6 +13,7 @@ import { ProfileSessionsCard } from "../profile/ProfileSessionsCard"; import { ProfileTotpCard } from "../profile/ProfileTotpCard"; import { useProfilePage } from "../profile/useProfilePage"; import { EmptyState, LoadingState } from "../../shared/ui"; +import { DemoDataCard } from "./DemoDataCard"; import { GroupsSettingsCard } from "./GroupsSettingsCard"; // «Настройки» (SPEC-HUB-0031 §8.6): один экран с вертикальными секциями — @@ -52,6 +53,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [] }: { use + + + {profileSections} ); diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css index dd7cde7..b2c0ecf 100644 --- a/apps/internal-ui/src/features/settings/styles.css +++ b/apps/internal-ui/src/features/settings/styles.css @@ -108,3 +108,83 @@ display: flex; justify-content: flex-end; } + +.settings-demo-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.settings-demo-head h3 { + margin: 0; +} + +.settings-demo-actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.settings-demo-status { + color: var(--n-4); + font-size: 12.5px; + font-weight: 500; +} + +.settings-demo-status.is-installed { + color: var(--success-text); +} + +.settings-demo-status.is-failed { + color: var(--error-text); +} + +.settings-demo-accounts { + display: flex; + flex-direction: column; + margin-top: 4px; +} + +.settings-demo-account { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + align-items: center; + gap: 14px; + padding: 8px 2px; + border-bottom: 1px solid var(--n-9); +} + +.settings-demo-account > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-demo-account strong { + color: var(--n-1); + font-size: 13.5px; + font-weight: 600; +} + +.settings-demo-account small { + color: var(--n-4); + font-size: 12px; +} + +.settings-demo-account code { + padding: 3px 8px; + border-radius: 6px; + background: var(--n-10); + border: 1px solid var(--n-8); + color: var(--n-2); + font-size: 12.5px; + user-select: all; +} + +@media (max-width: 720px) { + .settings-demo-account { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/compose.dev.yaml b/compose.dev.yaml index 7b39c21..d3687ac 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -118,32 +118,6 @@ services: - /app/node_modules - /app/apps/web-chat/node_modules - # Явный одноразовый импорт демонстрационных данных. Сервис существует только - # в 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", "manage.py", "seed_demo", "--apply"] - working_dir: /app/apps/backend - environment: - CUS_DB_ROLE: migration - CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} - CUS_HELP_BASE_DOMAIN: localhost - CUS_HELP_PUBLIC_SCHEME: http - CUS_HELP_PUBLIC_PORT: "" - volumes: - - ./apps/backend:/app/apps/backend - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - # Dev: gateway — nginx с local.conf вместо Caddy (та же маршрутизация, HMR-friendly). gateway: image: nginx:1.27-alpine diff --git a/compose.yaml b/compose.yaml index 5716487..803c615 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,8 +49,8 @@ services: retries: 10 start_period: 20s - # One-shot init: миграции (ADR-HUB-0028 §one-shot init). Не запускает Edevs seed, - # не создаёт OWNER, не трогает demo — это отдельные ответственности (этап 2). + # One-shot init: только миграции (ADR-HUB-0028 §one-shot init). Владельца и + # демо-данные создаёт мастер первого запуска в браузере (/api/v1/setup/). # collectstatic испечён в backend-образ, здесь не вызывается. init: image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} diff --git a/scripts/seed-demo.ps1 b/scripts/seed-demo.ps1 deleted file mode 100644 index f58a6a6..0000000 --- a/scripts/seed-demo.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$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 diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..70fa8ee --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +# Локальный запуск Chatbolls (Linux/macOS): canonical compose.yaml + dev override. +# Параметры не нужны: владельца и демо-данные создаёт мастер первого запуска +# в браузере (http://localhost). Режим поставки: ./scripts/start.sh [cloud|self-hosted] +set -eu + +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + cp .env.example .env +fi + +MODE="${1:-cloud}" +case "$MODE" in + cloud) export CUS_DELIVERY_MODE=CLOUD ;; + self-hosted) export CUS_DELIVERY_MODE=SELF_HOSTED ;; + *) echo "usage: $0 [cloud|self-hosted]" >&2; exit 2 ;; +esac + +exec docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up --build