diff --git a/.env.example b/.env.example
index 618cca9..d851fe2 100644
--- a/.env.example
+++ b/.env.example
@@ -1,58 +1,58 @@
-COMPOSE_PROJECT_NAME=edevs_hub
+COMPOSE_PROJECT_NAME=chatballs
-CUSTOCRM_APP_DOMAIN=app.localhost
-CUSTOCRM_PLATFORM_DOMAIN=platform.localhost
-CUS_HELP_BASE_DOMAIN=localhost
-CUS_HELP_PUBLIC_SCHEME=http
-CUS_HELP_PUBLIC_PORT=8080
-CUSTOCRM_ACME_EMAIL=local@example.invalid
-CUSTOCRM_ADMIN_PORT=18001
+CHATBALLS_APP_DOMAIN=app.localhost
+CHATBALLS_PLATFORM_DOMAIN=platform.localhost
+CHATBALLS_HELP_BASE_DOMAIN=localhost
+CHATBALLS_HELP_PUBLIC_SCHEME=http
+CHATBALLS_HELP_PUBLIC_PORT=8080
+CHATBALLS_ACME_EMAIL=local@example.invalid
+CHATBALLS_ADMIN_PORT=18001
-CUS_ENV=local
-CUS_DEBUG=true
-CUS_DELIVERY_MODE=CLOUD
-CUS_SECRET_KEY=change-me-only-for-local-development
-CUSTOCRM_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost
-CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175
-CUSTOCRM_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost
-CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011
-CUS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
+CHATBALLS_ENV=local
+CHATBALLS_DEBUG=true
+CHATBALLS_DELIVERY_MODE=CLOUD
+CHATBALLS_SECRET_KEY=change-me-only-for-local-development
+CHATBALLS_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost
+CHATBALLS_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175
+CHATBALLS_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost
+CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011
+CHATBALLS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
# Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из
-# CUS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
-CUS_FIELD_ENCRYPTION_KEY=
+# CHATBALLS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
+CHATBALLS_FIELD_ENCRYPTION_KEY=
# AI-провайдер. Локально по умолчанию тестовый адаптер; в production задайте ключ.
-# CUS_AI_PROVIDER=openrouter
-CUS_OPENROUTER_API_KEY=
-# CUS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
-# CUS_AI_REQUEST_TIMEOUT=30
-# CUS_AI_MAX_RETRIES=2
-# CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
+# CHATBALLS_AI_PROVIDER=openrouter
+CHATBALLS_OPENROUTER_API_KEY=
+# CHATBALLS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+# CHATBALLS_AI_REQUEST_TIMEOUT=30
+# CHATBALLS_AI_MAX_RETRIES=2
+# CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
-# Транспортная безопасность. Вне CUS_DEBUG включается автоматически; здесь — для переопределения.
-# CUS_COOKIE_SECURE=true
-# CUS_SSL_REDIRECT=true
-# CUS_HSTS_SECONDS=31536000
-# CUS_COOKIE_SAMESITE=Lax
+# Транспортная безопасность. Вне CHATBALLS_DEBUG включается автоматически; здесь — для переопределения.
+# CHATBALLS_COOKIE_SECURE=true
+# CHATBALLS_SSL_REDIRECT=true
+# CHATBALLS_HSTS_SECONDS=31536000
+# CHATBALLS_COOKIE_SAMESITE=Lax
# Email (по умолчанию console backend; для реальной отправки задайте SMTP).
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# EMAIL_HOST=
# EMAIL_HOST_USER=
# EMAIL_HOST_PASSWORD=
-# DEFAULT_FROM_EMAIL=CustoCRM
+# DEFAULT_FROM_EMAIL=Chatballs
INTERNAL_UI_BASE_URL=http://localhost:5173
-POSTGRES_DB=edevs_hub
-POSTGRES_USER=custocrm_bootstrap
-POSTGRES_PASSWORD=custocrm_bootstrap
-POSTGRES_APP_USER=custocrm_app
-POSTGRES_APP_PASSWORD=custocrm_app
-POSTGRES_PLATFORM_USER=custocrm_platform
-POSTGRES_PLATFORM_PASSWORD=custocrm_platform
-POSTGRES_MIGRATION_USER=custocrm_migration
-POSTGRES_MIGRATION_PASSWORD=custocrm_migration
+POSTGRES_DB=chatballs
+POSTGRES_USER=chatballs_bootstrap
+POSTGRES_PASSWORD=chatballs_bootstrap
+POSTGRES_APP_USER=chatballs_app
+POSTGRES_APP_PASSWORD=chatballs_app
+POSTGRES_PLATFORM_USER=chatballs_platform
+POSTGRES_PLATFORM_PASSWORD=chatballs_platform
+POSTGRES_MIGRATION_USER=chatballs_migration
+POSTGRES_MIGRATION_PASSWORD=chatballs_migration
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
@@ -60,26 +60,26 @@ REDIS_URL=redis://redis:6379/0
# Tenant-owned object storage. Production требует S3-compatible backend;
# local/test могут явно использовать filesystem.
-CUS_STORAGE_BACKEND=filesystem
-# CUS_S3_BUCKET=custocrm
-# CUS_S3_ENDPOINT_URL=https://s3.example.invalid
-# CUS_S3_REGION=ru-central1
-# CUS_S3_ACCESS_KEY=
-# CUS_S3_SECRET_KEY=
-# CUS_S3_ADDRESSING_STYLE=path
-# CUS_S3_URL_EXPIRY_SECONDS=900
+CHATBALLS_STORAGE_BACKEND=filesystem
+# CHATBALLS_S3_BUCKET=chatballs
+# CHATBALLS_S3_ENDPOINT_URL=https://s3.example.invalid
+# CHATBALLS_S3_REGION=ru-central1
+# CHATBALLS_S3_ACCESS_KEY=
+# CHATBALLS_S3_SECRET_KEY=
+# CHATBALLS_S3_ADDRESSING_STYLE=path
+# CHATBALLS_S3_URL_EXPIRY_SECONDS=900
# P2P calls: invite — 5 минут, access token — 1 час.
-CUS_CALL_INVITE_TTL_SECONDS=300
-CUS_CALL_ACCESS_TTL_SECONDS=3600
-CUS_CALL_CONNECT_GRACE_SECONDS=120
-CUS_CALL_RECONNECT_GRACE_SECONDS=60
+CHATBALLS_CALL_INVITE_TTL_SECONDS=300
+CHATBALLS_CALL_ACCESS_TTL_SECONDS=3600
+CHATBALLS_CALL_CONNECT_GRACE_SECONDS=120
+CHATBALLS_CALL_RECONNECT_GRACE_SECONDS=60
# STUN для WebRTC (через запятую). Локально обычно пусто: direct ICE на localhost.
-CUS_CALL_STUN_URLS=
+CHATBALLS_CALL_STUN_URLS=
# TURN (Coturn) — production-контур; локально пусто (direct/STUN достаточно).
-CUS_CALL_TURN_URLS=
-CUS_CALL_TURN_SECRET=
-CUS_CALL_TURN_TTL_SECONDS=3600
+CHATBALLS_CALL_TURN_URLS=
+CHATBALLS_CALL_TURN_SECRET=
+CHATBALLS_CALL_TURN_TTL_SECONDS=3600
INTERNAL_UI_PORT=5173
WEB_CHAT_PORT=5175
diff --git a/Caddyfile b/Caddyfile
index b596631..fb79d31 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -1,11 +1,11 @@
-# Caddyfile — единый HTTP/HTTPS public boundary CustoCRM (ADR-HUB-0028 §gateway).
+# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
# Подставляется в release bundle и монтируется в контейнер gateway.
# Caddy: TLS termination + ACME, HTTP->HTTPS redirect, WebSocket upgrade (native).
# App-маршрутизация делегирована frontend-контейнеру, platform API идёт в
# отдельный backend. Admin принципиально отсутствует в public gateway.
{
- email {$CUSTOCRM_ACME_EMAIL:}
+ email {$CHATBALLS_ACME_EMAIL:}
# Caddy admin API не публикуется наружу (default localhost:2019).
on_demand_tls {
ask http://backend-platform:8000/api/v1/gateway/help-domain/
@@ -29,7 +29,7 @@ https:// {
}
# Tenant-facing application surface.
-{$CUSTOCRM_APP_DOMAIN} {
+{$CHATBALLS_APP_DOMAIN} {
encode gzip zstd
reverse_proxy frontend:80 {
@@ -42,7 +42,7 @@ https:// {
# Edevs platform surface. На C01 опубликован только health endpoint; API
# управления tenant'ами появится на C06.
-{$CUSTOCRM_PLATFORM_DOMAIN} {
+{$CHATBALLS_PLATFORM_DOMAIN} {
encode gzip zstd
reverse_proxy backend-platform:8000 {
diff --git a/README.md b/README.md
index 9aca47c..4717be4 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Windows (PowerShell):
```powershell
git clone chatballs
-cd chatballs/code/custocrm
+cd chatballs/code/chatballs
.\scripts\start.ps1
```
@@ -19,7 +19,7 @@ Linux / macOS:
```bash
git clone chatballs
-cd chatballs/code/custocrm
+cd chatballs/code/chatballs
./scripts/start.sh
```
@@ -55,11 +55,35 @@ docker compose run --rm backend-app python manage.py seed_demo --organization --remove
```
-Редактируемые данные — `apps/backend/hub_platform/identity/demo_seed/data/`
+Редактируемые данные — `apps/backend/chatballs/identity/demo_seed/data/`
(JSON-манифест на домен, `media/` — вложения, аватары, голосовые). Голосовые
сообщения читаются из `media/voice/` (см. README там); если файла нет, сообщение
пропускается.
+### Обновление установки со старым именем (CustoCRM / hub → Chatballs)
+
+Установки, развёрнутые до переименования (compose-проект `edevs_hub`, база
+`edevs_hub`, роли Postgres `custocrm_*`, переменные `CUS_*`/`CUSTOCRM_*`),
+переводятся на новые имена одним скриптом — данные остаются на месте:
+
+```bash
+# prod: сначала переместите каталог релизов и инстанса
+mv /opt/custocrm /opt/chatballs
+CHATBALLS_INSTANCE_DIR=/opt/chatballs/instance ./deploy/migrate/rename-to-chatballs.sh
+./chatballs deploy
+```
+
+```bash
+# dev-стек из каталога репозитория
+CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml --env-file .env.example" deploy/migrate/rename-to-chatballs.sh
+docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up -d
+```
+
+Скрипт останавливает старый compose-проект, переписывает `.env` (резервная
+копия рядом, `.env.bak-custocrm`), переименовывает роли, схему RLS (и её GUC),
+базу и печатает итог. Cookie сессий меняют имя — пользователи входят заново.
+В CI/CD корень деплоя теперь `/opt/chatballs`.
+
### Режим поставки
По умолчанию локально запускается облачный режим. Коробочный режим — тем же
@@ -71,7 +95,7 @@ docker compose run --rm backend-app python manage.py seed_demo --organization QuerySet[Channel]:
@@ -138,8 +138,8 @@ def create_agent_card(
Канал создаётся без продукта с безопасной операторской политикой (дефолты
модели удовлетворяют P1-P5); код генерируется из имени и неизменен.
"""
- from hub_platform.channels import authorization
- from hub_platform.channels.services import _clean_name, _group_for_channel
+ from chatballs.channels import authorization
+ from chatballs.channels.services import _clean_name, _group_for_channel
authorization.require_organization_manage(context, operation="Создание агента")
clean_name = _clean_name(name)
@@ -178,7 +178,7 @@ def update_agent_card(
"knowledgeIds",
}
if ai_fields & set(body):
- from hub_platform.ai.services import AgentInput, update_agent
+ from chatballs.ai.services import AgentInput, update_agent
knowledge_ids = body.get("knowledgeIds")
if knowledge_ids is not None and (
@@ -234,8 +234,8 @@ def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
@transaction.atomic
def delete_agent_card(*, context: TenantContext, channel: Channel) -> None:
- from hub_platform.channels import authorization
- from hub_platform.channels.services import ChannelHasReferences
+ from chatballs.channels import authorization
+ from chatballs.channels.services import ChannelHasReferences
authorization.require_organization_manage(context, operation="Удаление агента")
blockers = agent_deletion_blockers(channel)
@@ -249,7 +249,7 @@ def set_agent_card_active(
*, context: TenantContext, channel: Channel, is_active: bool
) -> Channel:
"""Активность AI: включает/выключает автоответы, канал остаётся живым."""
- from hub_platform.ai.services import set_agent_active
+ from chatballs.ai.services import set_agent_active
agent = ensure_channel_agent(channel)
set_agent_active(context=context, agent=agent, is_active=is_active)
diff --git a/apps/backend/hub_platform/ai/agent_card_urls.py b/apps/backend/chatballs/ai/agent_card_urls.py
similarity index 95%
rename from apps/backend/hub_platform/ai/agent_card_urls.py
rename to apps/backend/chatballs/ai/agent_card_urls.py
index a311af4..daa9a00 100644
--- a/apps/backend/hub_platform/ai/agent_card_urls.py
+++ b/apps/backend/chatballs/ai/agent_card_urls.py
@@ -2,7 +2,7 @@
from django.urls import path
-from hub_platform.ai import agent_card_views as views
+from chatballs.ai import agent_card_views as views
urlpatterns = [
path("", views.AgentCardListView.as_view(), name="agent-card-list"),
diff --git a/apps/backend/hub_platform/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py
similarity index 94%
rename from apps/backend/hub_platform/ai/agent_card_views.py
rename to apps/backend/chatballs/ai/agent_card_views.py
index 11ea77b..c6c7a6c 100644
--- a/apps/backend/hub_platform/ai/agent_card_views.py
+++ b/apps/backend/chatballs/ai/agent_card_views.py
@@ -7,7 +7,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.ai.agent_card import (
+from chatballs.ai.agent_card import (
agent_card_for_context,
agent_card_payload,
agent_cards_for_context,
@@ -16,13 +16,13 @@ from hub_platform.ai.agent_card import (
set_agent_card_active,
update_agent_card,
)
-from hub_platform.ai.provider.base import ProviderError
-from hub_platform.api.permissions import HasCapability
-from hub_platform.channels import services as channel_services
-from hub_platform.channels.models import Channel
-from hub_platform.channels.runtime import run_channel_turn
-from hub_platform.channels.selectors import channel_for_context
-from hub_platform.identity.audit import record_audit_event
+from chatballs.ai.provider.base import ProviderError
+from chatballs.api.permissions import HasCapability
+from chatballs.channels import services as channel_services
+from chatballs.channels.models import Channel
+from chatballs.channels.runtime import run_channel_turn
+from chatballs.channels.selectors import channel_for_context
+from chatballs.identity.audit import record_audit_event
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
@@ -69,7 +69,7 @@ class AgentCardListView(APIView):
cards = cards.filter(group_id=int(group))
except ValueError:
return Response({"detail": "group must be an id or none"}, status=400)
- from hub_platform.ai.agent_card import ensure_channel_agent
+ from chatballs.ai.agent_card import ensure_channel_agent
items = []
for channel in cards:
@@ -112,7 +112,7 @@ class AgentCardDetailView(APIView):
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
- from hub_platform.ai.agent_card import ensure_channel_agent
+ from chatballs.ai.agent_card import ensure_channel_agent
ensure_channel_agent(channel)
return Response({"agent": agent_card_payload(channel)})
diff --git a/apps/backend/hub_platform/ai/agent_knowledge.py b/apps/backend/chatballs/ai/agent_knowledge.py
similarity index 87%
rename from apps/backend/hub_platform/ai/agent_knowledge.py
rename to apps/backend/chatballs/ai/agent_knowledge.py
index dcb15ed..674c01e 100644
--- a/apps/backend/hub_platform/ai/agent_knowledge.py
+++ b/apps/backend/chatballs/ai/agent_knowledge.py
@@ -1,9 +1,9 @@
from django.db.models import QuerySet
-from hub_platform.ai.models import AIAgent, Knowledge
-from hub_platform.channels.models import Channel
-from hub_platform.support_portals.models import PortalArticle
-from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
+from chatballs.ai.models import AIAgent, Knowledge
+from chatballs.channels.models import Channel
+from chatballs.support_portals.models import PortalArticle
+from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
# Библиотека знаний — общая для организации (ADR-HUB-0041 §8): агент использует
# только явно выбранные и включённые знания, областей видимости нет.
diff --git a/apps/backend/hub_platform/ai/api_errors.py b/apps/backend/chatballs/ai/api_errors.py
similarity index 100%
rename from apps/backend/hub_platform/ai/api_errors.py
rename to apps/backend/chatballs/ai/api_errors.py
diff --git a/apps/backend/hub_platform/ai/apps.py b/apps/backend/chatballs/ai/apps.py
similarity index 86%
rename from apps/backend/hub_platform/ai/apps.py
rename to apps/backend/chatballs/ai/apps.py
index d30aba9..11b3e4b 100644
--- a/apps/backend/hub_platform/ai/apps.py
+++ b/apps/backend/chatballs/ai/apps.py
@@ -4,5 +4,5 @@ from django.apps import AppConfig
class AiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "ai"
- name = "hub_platform.ai"
+ name = "chatballs.ai"
verbose_name = "AI agents, knowledge and releases"
diff --git a/apps/backend/hub_platform/ai/attachment_views.py b/apps/backend/chatballs/ai/attachment_views.py
similarity index 83%
rename from apps/backend/hub_platform/ai/attachment_views.py
rename to apps/backend/chatballs/ai/attachment_views.py
index cbb6d8a..a275b05 100644
--- a/apps/backend/hub_platform/ai/attachment_views.py
+++ b/apps/backend/chatballs/ai/attachment_views.py
@@ -3,11 +3,11 @@ from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.views import APIView
-from hub_platform.ai.models import KnowledgeAttachment
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.ingress import attachment_route
+from chatballs.ai.models import KnowledgeAttachment
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.ingress import attachment_route
class AttachmentDownloadView(APIView):
diff --git a/apps/backend/hub_platform/ai/bulk_views.py b/apps/backend/chatballs/ai/bulk_views.py
similarity index 94%
rename from apps/backend/hub_platform/ai/bulk_views.py
rename to apps/backend/chatballs/ai/bulk_views.py
index efccd50..a5aa810 100644
--- a/apps/backend/hub_platform/ai/bulk_views.py
+++ b/apps/backend/chatballs/ai/bulk_views.py
@@ -3,20 +3,20 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.ai.agent_attachments import (
+from chatballs.ai.agent_attachments import (
AgentLinkResult,
link_knowledge_to_agent,
link_portal_articles_to_agent,
)
-from hub_platform.ai.api_errors import validation_error_response
-from hub_platform.ai.knowledge_bulk import (
+from chatballs.ai.api_errors import validation_error_response
+from chatballs.ai.knowledge_bulk import (
add_category_knowledge_to_agent,
bulk_move_knowledge,
)
-from hub_platform.ai.models import AIAgent
-from hub_platform.ai.selectors import agent_for_employee
-from hub_platform.api.permissions import HasCapability
-from hub_platform.identity.audit import record_audit_event
+from chatballs.ai.models import AIAgent
+from chatballs.ai.selectors import agent_for_employee
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
def _positive_id(value: object, field: str) -> int:
diff --git a/apps/backend/hub_platform/ai/category_views.py b/apps/backend/chatballs/ai/category_views.py
similarity index 91%
rename from apps/backend/hub_platform/ai/category_views.py
rename to apps/backend/chatballs/ai/category_views.py
index 5e12ae7..f7c9e7b 100644
--- a/apps/backend/hub_platform/ai/category_views.py
+++ b/apps/backend/chatballs/ai/category_views.py
@@ -3,18 +3,18 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.ai.api_errors import validation_error_response
-from hub_platform.ai.knowledge_categories import (
+from chatballs.ai.api_errors import validation_error_response
+from chatballs.ai.knowledge_categories import (
create_category,
delete_category,
update_category,
)
-from hub_platform.ai.knowledge_policy import require_category_manage
-from hub_platform.ai.models import KnowledgeCategory
-from hub_platform.ai.selectors import category_tree_for_employee
-from hub_platform.ai.serializers import category_payload
-from hub_platform.api.permissions import HasCapability
-from hub_platform.identity.audit import record_audit_event
+from chatballs.ai.knowledge_policy import require_category_manage
+from chatballs.ai.models import KnowledgeCategory
+from chatballs.ai.selectors import category_tree_for_employee
+from chatballs.ai.serializers import category_payload
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
def _integer(value: object, field: str) -> int:
diff --git a/apps/backend/hub_platform/ai/chunking.py b/apps/backend/chatballs/ai/chunking.py
similarity index 100%
rename from apps/backend/hub_platform/ai/chunking.py
rename to apps/backend/chatballs/ai/chunking.py
diff --git a/apps/backend/hub_platform/ai/extraction.py b/apps/backend/chatballs/ai/extraction.py
similarity index 100%
rename from apps/backend/hub_platform/ai/extraction.py
rename to apps/backend/chatballs/ai/extraction.py
diff --git a/apps/backend/hub_platform/ai/indexing.py b/apps/backend/chatballs/ai/indexing.py
similarity index 89%
rename from apps/backend/hub_platform/ai/indexing.py
rename to apps/backend/chatballs/ai/indexing.py
index ab8d395..8de0ef6 100644
--- a/apps/backend/hub_platform/ai/indexing.py
+++ b/apps/backend/chatballs/ai/indexing.py
@@ -1,9 +1,9 @@
from django.conf import settings
-from hub_platform.ai.chunking import chunk_text
-from hub_platform.ai.invocation import embed_texts
-from hub_platform.ai.models import Knowledge, KnowledgeAttachment, KnowledgeFragment
-from hub_platform.ai.provider.base import ProviderError
+from chatballs.ai.chunking import chunk_text
+from chatballs.ai.invocation import embed_texts
+from chatballs.ai.models import Knowledge, KnowledgeAttachment, KnowledgeFragment
+from chatballs.ai.provider.base import ProviderError
def _store_fragments(*, organization, chunks: list[str], **source) -> list[KnowledgeFragment]:
@@ -16,7 +16,7 @@ def _store_fragments(*, organization, chunks: list[str], **source) -> list[Knowl
embeddings = embed_texts(
organization=organization,
texts=chunks,
- model=settings.CUS_AI_EMBEDDING_MODEL,
+ model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
purpose="knowledge_index",
)
vectors = [result.vector for result in embeddings]
@@ -59,7 +59,7 @@ def reindex_portal_article(article) -> list[KnowledgeFragment]:
текстом, который клиент видит в Help Center. У черновика и архива
фрагментов нет, поэтому в выдачу они не попадают.
"""
- from hub_platform.support_portals.statuses import ArticleStatus
+ from chatballs.support_portals.statuses import ArticleStatus
KnowledgeFragment.objects.filter(portal_article=article).delete()
revision = article.published_revision
diff --git a/apps/backend/hub_platform/ai/invocation.py b/apps/backend/chatballs/ai/invocation.py
similarity index 89%
rename from apps/backend/hub_platform/ai/invocation.py
rename to apps/backend/chatballs/ai/invocation.py
index 03fcf1b..04ebb21 100644
--- a/apps/backend/hub_platform/ai/invocation.py
+++ b/apps/backend/chatballs/ai/invocation.py
@@ -2,19 +2,19 @@ import time
from django.conf import settings
-from hub_platform.ai import limits, pricing
-from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
-from hub_platform.ai.pii import redact
-from hub_platform.ai.provider import routing
-from hub_platform.ai.provider.base import (
+from chatballs.ai import limits, pricing
+from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
+from chatballs.ai.pii import redact
+from chatballs.ai.provider import routing
+from chatballs.ai.provider.base import (
ChatMessage,
ChatResult,
EmbeddingResult,
LLMProvider,
ProviderError,
)
-from hub_platform.ai.provider.factory import get_provider
-from hub_platform.ai.provider.resilience import CircuitBreaker, call_with_resilience
+from chatballs.ai.provider.factory import get_provider
+from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
_breaker = CircuitBreaker()
@@ -71,7 +71,7 @@ def invoke_chat(
try:
result: ChatResult = call_with_resilience(
lambda: provider.chat(messages=safe_messages, model=model, params=params),
- retries=settings.CUS_AI_MAX_RETRIES,
+ retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker,
)
except ProviderError as error:
@@ -119,7 +119,7 @@ def embed_texts(
provider = get_provider(channel=channel)
results: list[EmbeddingResult] = call_with_resilience(
lambda: provider.embed(texts=texts, model=model),
- retries=settings.CUS_AI_MAX_RETRIES,
+ retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker,
)
tokens = sum(result.tokens for result in results)
diff --git a/apps/backend/hub_platform/ai/knowledge_api_inputs.py b/apps/backend/chatballs/ai/knowledge_api_inputs.py
similarity index 93%
rename from apps/backend/hub_platform/ai/knowledge_api_inputs.py
rename to apps/backend/chatballs/ai/knowledge_api_inputs.py
index d04f2d3..2287d09 100644
--- a/apps/backend/hub_platform/ai/knowledge_api_inputs.py
+++ b/apps/backend/chatballs/ai/knowledge_api_inputs.py
@@ -1,9 +1,9 @@
from django.core.exceptions import ValidationError
from rest_framework.request import Request
-from hub_platform.ai.knowledge_services import KnowledgeInput
-from hub_platform.ai.models import Knowledge
-from hub_platform.ai.selectors import KnowledgeFilters
+from chatballs.ai.knowledge_services import KnowledgeInput
+from chatballs.ai.models import Knowledge
+from chatballs.ai.selectors import KnowledgeFilters
def _positive_id(value: object, field: str) -> int:
diff --git a/apps/backend/hub_platform/ai/knowledge_bulk.py b/apps/backend/chatballs/ai/knowledge_bulk.py
similarity index 91%
rename from apps/backend/hub_platform/ai/knowledge_bulk.py
rename to apps/backend/chatballs/ai/knowledge_bulk.py
index c0156c8..4a93915 100644
--- a/apps/backend/hub_platform/ai/knowledge_bulk.py
+++ b/apps/backend/chatballs/ai/knowledge_bulk.py
@@ -5,16 +5,16 @@ from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.utils import timezone
-from hub_platform.ai.agent_knowledge import knowledge_available_to_channel
-from hub_platform.ai.knowledge_policy import employee_can_write_knowledge
-from hub_platform.ai.models import (
+from chatballs.ai.agent_knowledge import knowledge_available_to_channel
+from chatballs.ai.knowledge_policy import employee_can_write_knowledge
+from chatballs.ai.models import (
AIAgent,
Knowledge,
KnowledgeCategory,
)
-from hub_platform.ai.services import knowledge_for_agent_ids
-from hub_platform.channels.models import Channel
-from hub_platform.tenancy.context import TenantContext
+from chatballs.ai.services import knowledge_for_agent_ids
+from chatballs.channels.models import Channel
+from chatballs.tenancy.context import TenantContext
def _normalized_knowledge_ids(knowledge_ids: Iterable[int]) -> list[int]:
diff --git a/apps/backend/hub_platform/ai/knowledge_categories.py b/apps/backend/chatballs/ai/knowledge_categories.py
similarity index 94%
rename from apps/backend/hub_platform/ai/knowledge_categories.py
rename to apps/backend/chatballs/ai/knowledge_categories.py
index 30f7dec..5ab7699 100644
--- a/apps/backend/hub_platform/ai/knowledge_categories.py
+++ b/apps/backend/chatballs/ai/knowledge_categories.py
@@ -1,10 +1,10 @@
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
-from hub_platform.ai.models import KnowledgeCategory
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.context import TenantContext
+from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
+from chatballs.ai.models import KnowledgeCategory
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantContext
def ensure_uncategorized_category(organization: Organization) -> KnowledgeCategory:
diff --git a/apps/backend/hub_platform/ai/knowledge_import.py b/apps/backend/chatballs/ai/knowledge_import.py
similarity index 96%
rename from apps/backend/hub_platform/ai/knowledge_import.py
rename to apps/backend/chatballs/ai/knowledge_import.py
index 7c9e28c..0432a56 100644
--- a/apps/backend/hub_platform/ai/knowledge_import.py
+++ b/apps/backend/chatballs/ai/knowledge_import.py
@@ -3,17 +3,17 @@ from dataclasses import dataclass, field
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
-from hub_platform.ai.knowledge_policy import (
+from chatballs.ai.knowledge_policy import (
employee_can_write_knowledge,
require_knowledge_create,
)
-from hub_platform.ai.knowledge_services import (
+from chatballs.ai.knowledge_services import (
KnowledgeInput,
create_knowledge,
update_knowledge,
)
-from hub_platform.ai.models import Knowledge, KnowledgeCategory
-from hub_platform.tenancy.context import TenantContext
+from chatballs.ai.models import Knowledge, KnowledgeCategory
+from chatballs.tenancy.context import TenantContext
@dataclass(slots=True)
diff --git a/apps/backend/hub_platform/ai/knowledge_models.py b/apps/backend/chatballs/ai/knowledge_models.py
similarity index 95%
rename from apps/backend/hub_platform/ai/knowledge_models.py
rename to apps/backend/chatballs/ai/knowledge_models.py
index 6eadcbd..a5f649c 100644
--- a/apps/backend/hub_platform/ai/knowledge_models.py
+++ b/apps/backend/chatballs/ai/knowledge_models.py
@@ -1,8 +1,8 @@
from django.core.exceptions import ValidationError
from django.db import models
-from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
-from hub_platform.tenancy.models import TenantRelationModel
+from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
+from chatballs.tenancy.models import TenantRelationModel
class KnowledgeCategory(TenantRelationModel):
diff --git a/apps/backend/hub_platform/ai/knowledge_policy.py b/apps/backend/chatballs/ai/knowledge_policy.py
similarity index 92%
rename from apps/backend/hub_platform/ai/knowledge_policy.py
rename to apps/backend/chatballs/ai/knowledge_policy.py
index dd7ab5d..761ba70 100644
--- a/apps/backend/hub_platform/ai/knowledge_policy.py
+++ b/apps/backend/chatballs/ai/knowledge_policy.py
@@ -3,9 +3,9 @@ from __future__ import annotations
from django.core.exceptions import PermissionDenied
from django.db.models import QuerySet
-from hub_platform.identity.policy import has_capability_any_scope
-from hub_platform.ai.models import AIAgent, Knowledge
-from hub_platform.tenancy.context import TenantContext
+from chatballs.identity.policy import has_capability_any_scope
+from chatballs.ai.models import AIAgent, Knowledge
+from chatballs.tenancy.context import TenantContext
AI_VIEW = "ai.view"
AI_MANAGE = "ai.manage"
diff --git a/apps/backend/hub_platform/ai/knowledge_policy_test_base.py b/apps/backend/chatballs/ai/knowledge_policy_test_base.py
similarity index 92%
rename from apps/backend/hub_platform/ai/knowledge_policy_test_base.py
rename to apps/backend/chatballs/ai/knowledge_policy_test_base.py
index 13bb505..fc3b175 100644
--- a/apps/backend/hub_platform/ai/knowledge_policy_test_base.py
+++ b/apps/backend/chatballs/ai/knowledge_policy_test_base.py
@@ -1,17 +1,17 @@
from django.test import TestCase
-from hub_platform.ai.knowledge_categories import (
+from chatballs.ai.knowledge_categories import (
create_category,
ensure_uncategorized_category,
)
-from hub_platform.ai.models import Knowledge
-from hub_platform.identity.models import (
+from chatballs.ai.models import Knowledge
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
class KnowledgePolicyTestBase(TestCase):
diff --git a/apps/backend/hub_platform/ai/knowledge_services.py b/apps/backend/chatballs/ai/knowledge_services.py
similarity index 93%
rename from apps/backend/hub_platform/ai/knowledge_services.py
rename to apps/backend/chatballs/ai/knowledge_services.py
index cd459e8..1cd9b06 100644
--- a/apps/backend/hub_platform/ai/knowledge_services.py
+++ b/apps/backend/chatballs/ai/knowledge_services.py
@@ -4,17 +4,17 @@ from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
-from hub_platform.ai.extraction import extract_text
-from hub_platform.ai.indexing import reindex_knowledge
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.ai.models import (
+from chatballs.ai.extraction import extract_text
+from chatballs.ai.indexing import reindex_knowledge
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.ai.models import (
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
)
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.storage import adjust_storage_usage
-from hub_platform.tenancy.storage_quota import (
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.storage import adjust_storage_usage
+from chatballs.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
diff --git a/apps/backend/hub_platform/ai/knowledge_types.py b/apps/backend/chatballs/ai/knowledge_types.py
similarity index 100%
rename from apps/backend/hub_platform/ai/knowledge_types.py
rename to apps/backend/chatballs/ai/knowledge_types.py
diff --git a/apps/backend/chatballs/ai/limits.py b/apps/backend/chatballs/ai/limits.py
new file mode 100644
index 0000000..045fcd2
--- /dev/null
+++ b/apps/backend/chatballs/ai/limits.py
@@ -0,0 +1,32 @@
+from django.conf import settings
+from django.db.models import Sum
+from django.utils import timezone
+
+from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
+
+
+class LimitExceeded(Exception):
+ pass
+
+
+def _day_start():
+ now = timezone.localtime()
+ return now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+
+def daily_cost_micros(channel=None) -> int:
+ queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
+ if channel is not None:
+ queryset = queryset.filter(channel=channel)
+ return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
+
+
+def assert_within_limits(channel, agent) -> None:
+ global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
+ if global_limit and daily_cost_micros() >= global_limit:
+ raise LimitExceeded("Global daily AI cost limit reached")
+ # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
+ # в micro-USD. 1 цент = 10 000 micro-USD.
+ channel_limit = (agent.limits or {}).get("dailyCostUsd")
+ if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
+ raise LimitExceeded("Channel daily AI cost limit reached")
diff --git a/apps/backend/hub_platform/ai/migrations/0001_initial.py b/apps/backend/chatballs/ai/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0001_initial.py
rename to apps/backend/chatballs/ai/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/ai/migrations/0002_knowledge_and_agent_instructions.py b/apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py
similarity index 98%
rename from apps/backend/hub_platform/ai/migrations/0002_knowledge_and_agent_instructions.py
rename to apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py
index 5a9a811..52211fe 100644
--- a/apps/backend/hub_platform/ai/migrations/0002_knowledge_and_agent_instructions.py
+++ b/apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py
@@ -6,7 +6,7 @@ import uuid
import django.db.models.deletion
from django.db import migrations, models
-import hub_platform.ai.models
+import chatballs.ai.models
class Migration(migrations.Migration):
@@ -43,7 +43,7 @@ class Migration(migrations.Migration):
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
- ("file", models.FileField(max_length=512, upload_to=hub_platform.ai.models.attachment_upload_path)),
+ ("file", models.FileField(max_length=512, upload_to=chatballs.ai.models.attachment_upload_path)),
("original_name", models.CharField(max_length=255)),
("content_type", models.CharField(blank=True, max_length=128)),
("size", models.PositiveBigIntegerField(default=0)),
diff --git a/apps/backend/hub_platform/ai/migrations/0003_flatten_agent_config.py b/apps/backend/chatballs/ai/migrations/0003_flatten_agent_config.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0003_flatten_agent_config.py
rename to apps/backend/chatballs/ai/migrations/0003_flatten_agent_config.py
diff --git a/apps/backend/hub_platform/ai/migrations/0004_drop_documents_and_releases.py b/apps/backend/chatballs/ai/migrations/0004_drop_documents_and_releases.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0004_drop_documents_and_releases.py
rename to apps/backend/chatballs/ai/migrations/0004_drop_documents_and_releases.py
diff --git a/apps/backend/hub_platform/ai/migrations/0005_aiagent_organization_and_more.py b/apps/backend/chatballs/ai/migrations/0005_aiagent_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0005_aiagent_organization_and_more.py
rename to apps/backend/chatballs/ai/migrations/0005_aiagent_organization_and_more.py
diff --git a/apps/backend/hub_platform/ai/migrations/0006_alter_aiagent_organization_and_more.py b/apps/backend/chatballs/ai/migrations/0006_alter_aiagent_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0006_alter_aiagent_organization_and_more.py
rename to apps/backend/chatballs/ai/migrations/0006_alter_aiagent_organization_and_more.py
diff --git a/apps/backend/hub_platform/ai/migrations/0007_remove_aiagent_is_active_aiagent_lifecycle_version_and_more.py b/apps/backend/chatballs/ai/migrations/0007_remove_aiagent_is_active_aiagent_lifecycle_version_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0007_remove_aiagent_is_active_aiagent_lifecycle_version_and_more.py
rename to apps/backend/chatballs/ai/migrations/0007_remove_aiagent_is_active_aiagent_lifecycle_version_and_more.py
diff --git a/apps/backend/hub_platform/ai/migrations/0008_aiagent_credential_mode.py b/apps/backend/chatballs/ai/migrations/0008_aiagent_credential_mode.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0008_aiagent_credential_mode.py
rename to apps/backend/chatballs/ai/migrations/0008_aiagent_credential_mode.py
diff --git a/apps/backend/hub_platform/ai/migrations/0009_knowledge_hierarchy_and_visibility.py b/apps/backend/chatballs/ai/migrations/0009_knowledge_hierarchy_and_visibility.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0009_knowledge_hierarchy_and_visibility.py
rename to apps/backend/chatballs/ai/migrations/0009_knowledge_hierarchy_and_visibility.py
diff --git a/apps/backend/hub_platform/ai/migrations/0010_backfill_knowledge_category.py b/apps/backend/chatballs/ai/migrations/0010_backfill_knowledge_category.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0010_backfill_knowledge_category.py
rename to apps/backend/chatballs/ai/migrations/0010_backfill_knowledge_category.py
diff --git a/apps/backend/hub_platform/ai/migrations/0011_require_knowledge_category.py b/apps/backend/chatballs/ai/migrations/0011_require_knowledge_category.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0011_require_knowledge_category.py
rename to apps/backend/chatballs/ai/migrations/0011_require_knowledge_category.py
diff --git a/apps/backend/hub_platform/ai/migrations/0012_agent_provider_integration.py b/apps/backend/chatballs/ai/migrations/0012_agent_provider_integration.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0012_agent_provider_integration.py
rename to apps/backend/chatballs/ai/migrations/0012_agent_provider_integration.py
diff --git a/apps/backend/hub_platform/ai/migrations/0013_portal_article_knowledge.py b/apps/backend/chatballs/ai/migrations/0013_portal_article_knowledge.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0013_portal_article_knowledge.py
rename to apps/backend/chatballs/ai/migrations/0013_portal_article_knowledge.py
diff --git a/apps/backend/hub_platform/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py b/apps/backend/chatballs/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py
rename to apps/backend/chatballs/ai/migrations/0014_remove_knowledgedepartment_department_and_more.py
diff --git a/apps/backend/hub_platform/ai/migrations/0015_backfill_channel_agents.py b/apps/backend/chatballs/ai/migrations/0015_backfill_channel_agents.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0015_backfill_channel_agents.py
rename to apps/backend/chatballs/ai/migrations/0015_backfill_channel_agents.py
diff --git a/apps/backend/hub_platform/ai/migrations/0016_remove_credential_mode.py b/apps/backend/chatballs/ai/migrations/0016_remove_credential_mode.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/0016_remove_credential_mode.py
rename to apps/backend/chatballs/ai/migrations/0016_remove_credential_mode.py
diff --git a/apps/backend/hub_platform/ai/migrations/__init__.py b/apps/backend/chatballs/ai/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/ai/migrations/__init__.py
rename to apps/backend/chatballs/ai/migrations/__init__.py
diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py
new file mode 100644
index 0000000..63ed5e3
--- /dev/null
+++ b/apps/backend/chatballs/ai/models.py
@@ -0,0 +1,257 @@
+import uuid
+
+from django.core.exceptions import ValidationError
+from django.db import models
+from pgvector.django import VectorField
+
+from chatballs.tenancy.models import TenantRelationModel
+
+# Один основной агент на канал обработки (ADR-HUB-0019, ADR-HUB-0023).
+DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
+
+
+class AIAgentStatus(models.TextChoices):
+ DRAFT = "DRAFT", "Draft"
+ ACTIVE = "ACTIVE", "Active"
+ DISABLED = "DISABLED", "Disabled"
+ ARCHIVED = "ARCHIVED", "Archived"
+
+
+# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-HUB-0042 §3):
+# AI работает только через провайдера организации (AIAgent.provider_integration).
+
+
+# --- Знания: общая библиотека организации с иерархией категорий
+# (ADR-HUB-0023, ADR-HUB-0041 §8: областей видимости по отделам нет) ---
+
+
+class Knowledge(models.Model):
+ organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="knowledge_items")
+ category = models.ForeignKey(
+ "ai.KnowledgeCategory",
+ on_delete=models.PROTECT,
+ related_name="knowledge_items",
+ )
+ title = models.CharField(max_length=255)
+ description = models.CharField(max_length=500, blank=True)
+ content = models.TextField(blank=True) # Markdown
+ is_enabled = models.BooleanField(default=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["title"]
+ verbose_name_plural = "knowledge"
+
+ def clean(self) -> None:
+ super().clean()
+ if self.category_id is not None and self.category.organization_id != self.organization_id:
+ raise ValidationError({"category": "Category belongs to another organization"})
+
+ def save(self, *args: object, **kwargs: object) -> None:
+ self.clean()
+ super().save(*args, **kwargs)
+
+ def __str__(self) -> str:
+ return f"knowledge:{self.organization_id}/{self.title}"
+
+
+# Django imports only models.py by convention. Re-export the related knowledge
+# models after Knowledge exists so they are registered without growing this file.
+from chatballs.ai.knowledge_models import ( # noqa: E402, F401
+ KnowledgeCategory,
+)
+
+
+def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:
+ organization = instance.knowledge.organization
+ return (
+ f"organizations/{organization.public_id}/knowledge/"
+ f"{instance.knowledge_id}/{instance.public_id}/{filename}"
+ )
+
+
+class KnowledgeAttachment(TenantRelationModel):
+ tenant_relation_fields = ("knowledge",)
+ knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="attachments")
+ # Непредсказуемый идентификатор публичной ссылки скачивания (ADR-HUB-0023):
+ # агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
+ public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
+ file = models.FileField(upload_to=attachment_upload_path, max_length=512)
+ # Оригинальное имя сохраняется и уникально в рамках знания: текст знания
+ # ссылается на вложение по имени.
+ original_name = models.CharField(max_length=255)
+ content_type = models.CharField(max_length=128, blank=True)
+ size = models.PositiveBigIntegerField(default=0)
+ # Текст, извлечённый из файла (md/txt/pdf/docx) для retrieval-индексации.
+ extracted_text = models.TextField(blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["original_name"]
+ constraints = [models.UniqueConstraint(fields=["knowledge", "original_name"], name="uniq_attachment_knowledge_name")]
+
+ def __str__(self) -> str:
+ return f"attachment:{self.knowledge_id}/{self.original_name}"
+
+ def public_url(self) -> str:
+ # Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
+ # строится от публичного адреса Hub, а не от request.
+ from django.conf import settings
+ from django.urls import reverse
+
+ path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id})
+ return settings.CHATBALLS_PUBLIC_BASE_URL.rstrip("/") + path
+
+
+class KnowledgeFragment(TenantRelationModel):
+ tenant_relation_fields = ("knowledge", "portal_article")
+ # Чанк источника + его эмбеддинг (pgvector). ADR-HUB-0016. Источник — либо
+ # знание библиотеки, либо опубликованная статья портала поддержки: обе
+ # ветки индексируются одинаково, чтобы retrieval оставался одним запросом.
+ # Перестраивается при каждом изменении содержимого источника.
+ knowledge = models.ForeignKey(
+ Knowledge,
+ on_delete=models.CASCADE,
+ related_name="fragments",
+ null=True,
+ blank=True,
+ )
+ portal_article = models.ForeignKey(
+ "support_portals.PortalArticle",
+ on_delete=models.CASCADE,
+ related_name="fragments",
+ null=True,
+ blank=True,
+ )
+ chunk_index = models.PositiveIntegerField()
+ content = models.TextField()
+ # Размерность не фиксируется: совместимость локального и production embedding-провайдера.
+ embedding = VectorField(null=True, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["knowledge_id", "portal_article_id", "chunk_index"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["knowledge", "chunk_index"], name="uniq_fragment_knowledge_chunk"
+ ),
+ models.UniqueConstraint(
+ fields=["portal_article", "chunk_index"], name="uniq_fragment_article_chunk"
+ ),
+ models.CheckConstraint(
+ condition=(
+ models.Q(knowledge__isnull=False, portal_article__isnull=True)
+ | models.Q(knowledge__isnull=True, portal_article__isnull=False)
+ ),
+ name="fragment_single_source",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ source = (
+ f"knowledge:{self.knowledge_id}"
+ if self.knowledge_id
+ else f"article:{self.portal_article_id}"
+ )
+ return f"fragment:{source}/{self.chunk_index}"
+
+ @property
+ def source_title(self) -> str:
+ """Заголовок источника для цитирования в системном промпте."""
+ if self.knowledge_id is not None:
+ return self.knowledge.title
+ revision = self.portal_article.published_revision
+ return revision.title if revision is not None else self.portal_article.slug
+
+
+# --- Агент канала: одна сущность, без релизов (ADR-HUB-0023) ---
+
+
+class AIAgent(TenantRelationModel):
+ tenant_relation_fields = ("channel", "provider_integration")
+ channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
+ # BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
+ # чего credential_mode и model были на агенте, а секрет — на канале: одно
+ # решение в двух таблицах, и форма агента скрыто писала в канал.
+ provider_integration = models.ForeignKey(
+ "integrations.Integration",
+ on_delete=models.PROTECT,
+ related_name="agents",
+ null=True,
+ blank=True,
+ )
+ name = models.CharField(max_length=255)
+ status = models.CharField(
+ max_length=16,
+ choices=AIAgentStatus.choices,
+ default=AIAgentStatus.DRAFT,
+ )
+ lifecycle_version = models.PositiveIntegerField(default=0)
+ model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
+ model_params = models.JSONField(default=dict, blank=True)
+ # Инструкции из трёх частей; системный промпт собирается в этом порядке.
+ persona = models.TextField(blank=True) # кто он и что он
+ tone = models.TextField(blank=True) # как он должен говорить
+ instructions = models.TextField(blank=True) # правила работы
+ # Выбор знаний из библиотеки организации.
+ knowledge_items = models.ManyToManyField(Knowledge, blank=True, related_name="agents")
+ # Статьи портала поддержки остаются в support_portals: агент ссылается на
+ # них, а не на копию, поэтому правка статьи сразу меняет ответы агента.
+ portal_articles = models.ManyToManyField(
+ "support_portals.PortalArticle",
+ blank=True,
+ related_name="agents",
+ )
+ allowed_tools = models.JSONField(default=list, blank=True)
+ # Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
+ limits = models.JSONField(default=dict, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self) -> str:
+ return f"{self.channel.code}:agent"
+
+ @property
+ def is_active(self) -> bool:
+ return self.status == AIAgentStatus.ACTIVE
+
+ @is_active.setter
+ def is_active(self, value: bool) -> None:
+ self.status = AIAgentStatus.ACTIVE if value else AIAgentStatus.DISABLED
+
+
+# --- LLM usage accounting (tokens, cost) ---
+
+
+class LlmInvocationStatus(models.TextChoices):
+ SUCCESS = "SUCCESS", "Успех"
+ ERROR = "ERROR", "Ошибка"
+ BLOCKED = "BLOCKED", "Заблокировано лимитом"
+
+
+class LlmInvocation(TenantRelationModel):
+ tenant_relation_fields = ("channel", "product")
+ # Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый.
+ channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")
+ product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True)
+ purpose = models.CharField(max_length=64)
+ operation = models.CharField(max_length=16) # chat | embedding
+ model = models.CharField(max_length=128, blank=True)
+ prompt_tokens = models.PositiveIntegerField(default=0)
+ completion_tokens = models.PositiveIntegerField(default=0)
+ total_tokens = models.PositiveIntegerField(default=0)
+ cost_micros = models.PositiveBigIntegerField(default=0)
+ currency = models.CharField(max_length=3, default="USD")
+ latency_ms = models.PositiveIntegerField(default=0)
+ status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
+ error = models.TextField(blank=True)
+ used_fragment_ids = models.JSONField(default=list, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+ indexes = [models.Index(fields=["channel", "created_at"])]
+
+ def __str__(self) -> str:
+ return f"llm:{self.channel_id}/{self.operation}/{self.status}"
diff --git a/apps/backend/hub_platform/ai/pii.py b/apps/backend/chatballs/ai/pii.py
similarity index 100%
rename from apps/backend/hub_platform/ai/pii.py
rename to apps/backend/chatballs/ai/pii.py
diff --git a/apps/backend/hub_platform/ai/pricing.py b/apps/backend/chatballs/ai/pricing.py
similarity index 50%
rename from apps/backend/hub_platform/ai/pricing.py
rename to apps/backend/chatballs/ai/pricing.py
index 2f68c6f..ac92a5a 100644
--- a/apps/backend/hub_platform/ai/pricing.py
+++ b/apps/backend/chatballs/ai/pricing.py
@@ -1,17 +1,17 @@
-from django.conf import settings
-
-# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
-# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
-# Реальные/уточнённые цены задаются через CUS_AI_PRICING.
-DEFAULT_PRICING = {
- "openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
- "anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
-}
-
-
-def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
- table = {**DEFAULT_PRICING, **getattr(settings, "CUS_AI_PRICING", {})}
- price = table.get(model)
- if not price:
- return 0
- return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
+from django.conf import settings
+
+# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
+# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
+# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
+DEFAULT_PRICING = {
+ "openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
+ "anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
+}
+
+
+def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
+ table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
+ price = table.get(model)
+ if not price:
+ return 0
+ return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
diff --git a/apps/backend/hub_platform/ai/provider/__init__.py b/apps/backend/chatballs/ai/provider/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/ai/provider/__init__.py
rename to apps/backend/chatballs/ai/provider/__init__.py
diff --git a/apps/backend/hub_platform/ai/provider/base.py b/apps/backend/chatballs/ai/provider/base.py
similarity index 100%
rename from apps/backend/hub_platform/ai/provider/base.py
rename to apps/backend/chatballs/ai/provider/base.py
diff --git a/apps/backend/hub_platform/ai/provider/custom.py b/apps/backend/chatballs/ai/provider/custom.py
similarity index 53%
rename from apps/backend/hub_platform/ai/provider/custom.py
rename to apps/backend/chatballs/ai/provider/custom.py
index 2e46c6f..dc4f208 100644
--- a/apps/backend/hub_platform/ai/provider/custom.py
+++ b/apps/backend/chatballs/ai/provider/custom.py
@@ -1,16 +1,16 @@
-from hub_platform.ai.provider.openrouter import OpenRouterProvider
-
-
-class CustomProvider(OpenRouterProvider):
- """Generic OpenAI-compatible BYOK adapter (ADR-HUB-0034).
-
- Reuses the OpenRouter HTTP layer verbatim: the contract is identical
- (POST /chat/completions, POST /embeddings, Authorization: Bearer ,
- response with choices[0].message.content and usage). The only difference
- from OpenRouter is the absence of a model catalog — the model identifier
- is supplied by the integration owner as free text and read at runtime
- (ADR-HUB-0034 §4). The distinct name lets routing and accounting tell the
- two BYOK modes apart.
- """
-
- name = "custom"
+from chatballs.ai.provider.openrouter import OpenRouterProvider
+
+
+class CustomProvider(OpenRouterProvider):
+ """Generic OpenAI-compatible BYOK adapter (ADR-HUB-0034).
+
+ Reuses the OpenRouter HTTP layer verbatim: the contract is identical
+ (POST /chat/completions, POST /embeddings, Authorization: Bearer ,
+ response with choices[0].message.content and usage). The only difference
+ from OpenRouter is the absence of a model catalog — the model identifier
+ is supplied by the integration owner as free text and read at runtime
+ (ADR-HUB-0034 §4). The distinct name lets routing and accounting tell the
+ two BYOK modes apart.
+ """
+
+ name = "custom"
diff --git a/apps/backend/hub_platform/ai/provider/demo.py b/apps/backend/chatballs/ai/provider/demo.py
similarity index 98%
rename from apps/backend/hub_platform/ai/provider/demo.py
rename to apps/backend/chatballs/ai/provider/demo.py
index 5845faa..6becf02 100644
--- a/apps/backend/hub_platform/ai/provider/demo.py
+++ b/apps/backend/chatballs/ai/provider/demo.py
@@ -15,7 +15,7 @@ import hashlib
import math
import re
-from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
+from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
EMBEDDING_DIM = 16
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
diff --git a/apps/backend/hub_platform/ai/provider/factory.py b/apps/backend/chatballs/ai/provider/factory.py
similarity index 83%
rename from apps/backend/hub_platform/ai/provider/factory.py
rename to apps/backend/chatballs/ai/provider/factory.py
index ea0a606..39e315a 100644
--- a/apps/backend/hub_platform/ai/provider/factory.py
+++ b/apps/backend/chatballs/ai/provider/factory.py
@@ -1,9 +1,9 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
-from hub_platform.ai.provider import routing
-from hub_platform.ai.provider.base import LLMProvider, ProviderError
-from hub_platform.ai.provider.local import LocalProvider
+from chatballs.ai.provider import routing
+from chatballs.ai.provider.base import LLMProvider, ProviderError
+from chatballs.ai.provider.local import LocalProvider
def _test_provider() -> LLMProvider:
@@ -21,7 +21,7 @@ def get_provider(*, channel=None) -> LLMProvider:
to resolve and fails cleanly (callers treat ProviderError as "no embeddings",
lexical search keeps working).
"""
- if settings.CUS_AI_PROVIDER == "test":
+ if settings.CHATBALLS_AI_PROVIDER == "test":
return _test_provider()
if channel is None:
diff --git a/apps/backend/hub_platform/ai/provider/local.py b/apps/backend/chatballs/ai/provider/local.py
similarity index 94%
rename from apps/backend/hub_platform/ai/provider/local.py
rename to apps/backend/chatballs/ai/provider/local.py
index eddf329..f4a224d 100644
--- a/apps/backend/hub_platform/ai/provider/local.py
+++ b/apps/backend/chatballs/ai/provider/local.py
@@ -1,7 +1,7 @@
import hashlib
import math
-from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
+from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
# Размерность согласуется со слайсом 3 (pgvector); для тестового провайдера фиксирована.
EMBEDDING_DIM = 16
diff --git a/apps/backend/hub_platform/ai/provider/openai_http.py b/apps/backend/chatballs/ai/provider/openai_http.py
similarity index 50%
rename from apps/backend/hub_platform/ai/provider/openai_http.py
rename to apps/backend/chatballs/ai/provider/openai_http.py
index a52390a..f17419e 100644
--- a/apps/backend/hub_platform/ai/provider/openai_http.py
+++ b/apps/backend/chatballs/ai/provider/openai_http.py
@@ -1,108 +1,108 @@
-"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
-ADR-HUB-0034 §3).
-
-The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
-speak the same Chat Completions shape:
-
-- POST /chat/completions with {model, messages, ...}; response has
- choices[0].message.content and usage (optionally usage.cost in USD).
-- POST /embeddings with {model, input}; response has data[].embedding and usage.
-- Authorization: Bearer .
-
-This module owns the HTTP transport and response parsing so the three adapters
-do not duplicate it. Adapters stay responsible for their own product semantics
-(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
-"""
-
-from __future__ import annotations
-
-import http.client
-import json
-import urllib.error
-import urllib.request
-
-from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
-from hub_platform.integrations.proxy import build_opener
-
-
-def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
- """POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
-
- Translates transport errors into ProviderError so callers can apply the
- circuit breaker uniformly.
- """
- request = urllib.request.Request(
- f"{base_url.rstrip('/')}{path}",
- data=json.dumps(payload).encode("utf-8"),
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
- method="POST",
- )
- try:
- with build_opener(proxy_url).open(request, timeout=timeout) as response:
- return json.loads(response.read().decode("utf-8"))
- # http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
- # — those are not OSError, so they would slip past ProviderError otherwise.
- except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
- raise ProviderError(f"{type(error).__name__}: {error}") from error
-
-
-def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_url: str = "") -> dict:
- """GET {base_url}{path} with Bearer auth; return parsed JSON (used for /models)."""
- request = urllib.request.Request(
- f"{base_url.rstrip('/')}{path}",
- headers={"Authorization": f"Bearer {api_key}"},
- method="GET",
- )
- try:
- with build_opener(proxy_url).open(request, timeout=timeout) as response:
- body = response.read().decode("utf-8")
- return json.loads(body) if body else {}
- except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
- raise ProviderError(f"{type(error).__name__}: {error}") from error
-
-
-def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
- timeout: float, proxy_url: str = "", params: dict | None = None,
- include_cost: bool = False) -> ChatResult:
- """POST /chat/completions and parse the OpenAI-shaped response.
-
- `include_cost=True` requests the OpenRouter-style usage.include flag and reads
- usage.cost (USD, converted to micros). Providers that do not report cost
- (Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
- """
- payload: dict = {
- "model": model,
- "messages": [{"role": m.role, "content": m.content} for m in messages],
- **(params or {}),
- }
- if include_cost:
- payload["usage"] = {"include": True}
- data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
- payload=payload, timeout=timeout, proxy_url=proxy_url)
- try:
- text = data["choices"][0]["message"]["content"]
- except (KeyError, IndexError, TypeError) as error:
- raise ProviderError(f"Unexpected provider response: {error}") from error
- usage = data.get("usage") or {}
- cost = usage.get("cost")
- return ChatResult(
- text=text,
- model=data.get("model", model),
- prompt_tokens=int(usage.get("prompt_tokens", 0)),
- completion_tokens=int(usage.get("completion_tokens", 0)),
- cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
- )
-
-
-def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
- timeout: float, proxy_url: str = "") -> list[EmbeddingResult]:
- """POST /embeddings and parse the OpenAI-shaped response."""
- data = post_json(base_url=base_url, path="/embeddings", api_key=api_key,
- payload={"model": model, "input": texts}, timeout=timeout, proxy_url=proxy_url)
- try:
- items = data["data"]
- except (KeyError, TypeError) as error:
- raise ProviderError(f"Unexpected provider response: {error}") from error
- usage = data.get("usage") or {}
- per_text = int(usage.get("prompt_tokens", 0)) // max(1, len(texts))
- return [EmbeddingResult(vector=item["embedding"], model=data.get("model", model), tokens=per_text) for item in items]
+"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
+ADR-HUB-0034 §3).
+
+The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
+speak the same Chat Completions shape:
+
+- POST /chat/completions with {model, messages, ...}; response has
+ choices[0].message.content and usage (optionally usage.cost in USD).
+- POST /embeddings with {model, input}; response has data[].embedding and usage.
+- Authorization: Bearer .
+
+This module owns the HTTP transport and response parsing so the three adapters
+do not duplicate it. Adapters stay responsible for their own product semantics
+(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
+"""
+
+from __future__ import annotations
+
+import http.client
+import json
+import urllib.error
+import urllib.request
+
+from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
+from chatballs.integrations.proxy import build_opener
+
+
+def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
+ """POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
+
+ Translates transport errors into ProviderError so callers can apply the
+ circuit breaker uniformly.
+ """
+ request = urllib.request.Request(
+ f"{base_url.rstrip('/')}{path}",
+ data=json.dumps(payload).encode("utf-8"),
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with build_opener(proxy_url).open(request, timeout=timeout) as response:
+ return json.loads(response.read().decode("utf-8"))
+ # http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
+ # — those are not OSError, so they would slip past ProviderError otherwise.
+ except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
+ raise ProviderError(f"{type(error).__name__}: {error}") from error
+
+
+def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_url: str = "") -> dict:
+ """GET {base_url}{path} with Bearer auth; return parsed JSON (used for /models)."""
+ request = urllib.request.Request(
+ f"{base_url.rstrip('/')}{path}",
+ headers={"Authorization": f"Bearer {api_key}"},
+ method="GET",
+ )
+ try:
+ with build_opener(proxy_url).open(request, timeout=timeout) as response:
+ body = response.read().decode("utf-8")
+ return json.loads(body) if body else {}
+ except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
+ raise ProviderError(f"{type(error).__name__}: {error}") from error
+
+
+def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
+ timeout: float, proxy_url: str = "", params: dict | None = None,
+ include_cost: bool = False) -> ChatResult:
+ """POST /chat/completions and parse the OpenAI-shaped response.
+
+ `include_cost=True` requests the OpenRouter-style usage.include flag and reads
+ usage.cost (USD, converted to micros). Providers that do not report cost
+ (Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
+ """
+ payload: dict = {
+ "model": model,
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
+ **(params or {}),
+ }
+ if include_cost:
+ payload["usage"] = {"include": True}
+ data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
+ payload=payload, timeout=timeout, proxy_url=proxy_url)
+ try:
+ text = data["choices"][0]["message"]["content"]
+ except (KeyError, IndexError, TypeError) as error:
+ raise ProviderError(f"Unexpected provider response: {error}") from error
+ usage = data.get("usage") or {}
+ cost = usage.get("cost")
+ return ChatResult(
+ text=text,
+ model=data.get("model", model),
+ prompt_tokens=int(usage.get("prompt_tokens", 0)),
+ completion_tokens=int(usage.get("completion_tokens", 0)),
+ cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
+ )
+
+
+def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
+ timeout: float, proxy_url: str = "") -> list[EmbeddingResult]:
+ """POST /embeddings and parse the OpenAI-shaped response."""
+ data = post_json(base_url=base_url, path="/embeddings", api_key=api_key,
+ payload={"model": model, "input": texts}, timeout=timeout, proxy_url=proxy_url)
+ try:
+ items = data["data"]
+ except (KeyError, TypeError) as error:
+ raise ProviderError(f"Unexpected provider response: {error}") from error
+ usage = data.get("usage") or {}
+ per_text = int(usage.get("prompt_tokens", 0)) // max(1, len(texts))
+ return [EmbeddingResult(vector=item["embedding"], model=data.get("model", model), tokens=per_text) for item in items]
diff --git a/apps/backend/hub_platform/ai/provider/openrouter.py b/apps/backend/chatballs/ai/provider/openrouter.py
similarity index 89%
rename from apps/backend/hub_platform/ai/provider/openrouter.py
rename to apps/backend/chatballs/ai/provider/openrouter.py
index 6d551bb..2f9a6a1 100644
--- a/apps/backend/hub_platform/ai/provider/openrouter.py
+++ b/apps/backend/chatballs/ai/provider/openrouter.py
@@ -1,5 +1,5 @@
-from hub_platform.ai.provider import openai_http
-from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
+from chatballs.ai.provider import openai_http
+from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
class OpenRouterProvider(LLMProvider):
@@ -40,9 +40,9 @@ class OpenRouterProvider(LLMProvider):
import urllib.error
import urllib.request
- from hub_platform.ai.provider.base import ProviderError
- from hub_platform.conversations.transports.base import multipart_body
- from hub_platform.integrations.proxy import build_opener
+ from chatballs.ai.provider.base import ProviderError
+ from chatballs.conversations.transports.base import multipart_body
+ from chatballs.integrations.proxy import build_opener
body, body_type = multipart_body(
{"model": model},
diff --git a/apps/backend/hub_platform/ai/provider/resilience.py b/apps/backend/chatballs/ai/provider/resilience.py
similarity index 96%
rename from apps/backend/hub_platform/ai/provider/resilience.py
rename to apps/backend/chatballs/ai/provider/resilience.py
index ce0d0dc..7ae4404 100644
--- a/apps/backend/hub_platform/ai/provider/resilience.py
+++ b/apps/backend/chatballs/ai/provider/resilience.py
@@ -1,7 +1,7 @@
import time
from typing import Callable
-from hub_platform.ai.provider.base import ProviderError
+from chatballs.ai.provider.base import ProviderError
class CircuitBreakerOpen(ProviderError):
diff --git a/apps/backend/hub_platform/ai/provider/routing.py b/apps/backend/chatballs/ai/provider/routing.py
similarity index 90%
rename from apps/backend/hub_platform/ai/provider/routing.py
rename to apps/backend/chatballs/ai/provider/routing.py
index e077ab7..af62d14 100644
--- a/apps/backend/hub_platform/ai/provider/routing.py
+++ b/apps/backend/chatballs/ai/provider/routing.py
@@ -21,11 +21,11 @@ at runtime and overrides `AIAgent.model`.
from __future__ import annotations
-from hub_platform.ai.provider.base import LLMProvider, ProviderError
-from hub_platform.ai.provider.custom import CustomProvider
-from hub_platform.ai.provider.demo import DemoProvider
-from hub_platform.ai.provider.openrouter import OpenRouterProvider
-from hub_platform.integrations.models import Integration, IntegrationProvider
+from chatballs.ai.provider.base import LLMProvider, ProviderError
+from chatballs.ai.provider.custom import CustomProvider
+from chatballs.ai.provider.demo import DemoProvider
+from chatballs.ai.provider.openrouter import OpenRouterProvider
+from chatballs.integrations.models import Integration, IntegrationProvider
class IntegrationNotConfigured(ProviderError):
@@ -80,8 +80,8 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
if integration.provider == IntegrationProvider.OPENROUTER:
return OpenRouterProvider(
api_key=integration.secret,
- base_url=integration.config.get("base_url") or settings.CUS_OPENROUTER_BASE_URL,
- timeout=settings.CUS_AI_REQUEST_TIMEOUT,
+ base_url=integration.config.get("base_url") or settings.CHATBALLS_OPENROUTER_BASE_URL,
+ timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
proxy_url=integration.config.get("proxy_url", ""),
)
if integration.provider == IntegrationProvider.DEMO:
@@ -90,7 +90,7 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
return CustomProvider(
api_key=integration.secret,
base_url=integration.config["base_url"],
- timeout=settings.CUS_AI_REQUEST_TIMEOUT,
+ timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
proxy_url=integration.config.get("proxy_url", ""),
)
raise IntegrationNotConfigured(
diff --git a/apps/backend/hub_platform/ai/provider_selection.py b/apps/backend/chatballs/ai/provider_selection.py
similarity index 94%
rename from apps/backend/hub_platform/ai/provider_selection.py
rename to apps/backend/chatballs/ai/provider_selection.py
index 4eff0ec..ae26463 100644
--- a/apps/backend/hub_platform/ai/provider_selection.py
+++ b/apps/backend/chatballs/ai/provider_selection.py
@@ -11,12 +11,12 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
-from hub_platform.integrations.models import (
+from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True, slots=True)
diff --git a/apps/backend/hub_platform/ai/public_urls.py b/apps/backend/chatballs/ai/public_urls.py
similarity index 68%
rename from apps/backend/hub_platform/ai/public_urls.py
rename to apps/backend/chatballs/ai/public_urls.py
index 62588fd..68d7984 100644
--- a/apps/backend/hub_platform/ai/public_urls.py
+++ b/apps/backend/chatballs/ai/public_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.ai.attachment_views import AttachmentDownloadView
+from chatballs.ai.attachment_views import AttachmentDownloadView
urlpatterns = [
path("files//", AttachmentDownloadView.as_view(), name="ai-attachment-download"),
diff --git a/apps/backend/hub_platform/ai/retrieval.py b/apps/backend/chatballs/ai/retrieval.py
similarity index 90%
rename from apps/backend/hub_platform/ai/retrieval.py
rename to apps/backend/chatballs/ai/retrieval.py
index 78f0fa7..b446787 100644
--- a/apps/backend/hub_platform/ai/retrieval.py
+++ b/apps/backend/chatballs/ai/retrieval.py
@@ -3,13 +3,13 @@ from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from django.db.models import Q
from pgvector.django import CosineDistance
-from hub_platform.ai.agent_knowledge import (
+from chatballs.ai.agent_knowledge import (
runtime_knowledge_for_agent,
runtime_portal_articles_for_agent,
)
-from hub_platform.ai.invocation import embed_texts
-from hub_platform.ai.models import AIAgent, KnowledgeFragment
-from hub_platform.ai.provider.base import ProviderError
+from chatballs.ai.invocation import embed_texts
+from chatballs.ai.models import AIAgent, KnowledgeFragment
+from chatballs.ai.provider.base import ProviderError
def _agent_fragments(agent: AIAgent):
@@ -53,7 +53,7 @@ class KnowledgeRetriever:
query_vector = embed_texts(
channel=agent.channel,
texts=[query],
- model=settings.CUS_AI_EMBEDDING_MODEL,
+ model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
purpose="retrieval_query",
)[0].vector
semantic = semantic_search(agent, query_vector, limit=limit)
diff --git a/apps/backend/hub_platform/ai/runtime.py b/apps/backend/chatballs/ai/runtime.py
similarity index 94%
rename from apps/backend/hub_platform/ai/runtime.py
rename to apps/backend/chatballs/ai/runtime.py
index 6c4ea7a..0bff949 100644
--- a/apps/backend/hub_platform/ai/runtime.py
+++ b/apps/backend/chatballs/ai/runtime.py
@@ -1,14 +1,14 @@
from dataclasses import dataclass
-from hub_platform.ai.agent_knowledge import (
+from chatballs.ai.agent_knowledge import (
runtime_knowledge_for_agent,
runtime_portal_articles_for_agent,
)
-from hub_platform.ai.invocation import invoke_chat
-from hub_platform.ai.models import AIAgent, KnowledgeFragment
-from hub_platform.ai.provider.base import ChatMessage, ChatResult
-from hub_platform.ai.retrieval import KnowledgeRetriever
-from hub_platform.support_portals.addressing import article_public_url
+from chatballs.ai.invocation import invoke_chat
+from chatballs.ai.models import AIAgent, KnowledgeFragment
+from chatballs.ai.provider.base import ChatMessage, ChatResult
+from chatballs.ai.retrieval import KnowledgeRetriever
+from chatballs.support_portals.addressing import article_public_url
# Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от
# того, что написано в авторских инструкциях.
diff --git a/apps/backend/hub_platform/ai/selectors.py b/apps/backend/chatballs/ai/selectors.py
similarity index 92%
rename from apps/backend/hub_platform/ai/selectors.py
rename to apps/backend/chatballs/ai/selectors.py
index 55673e0..31d0063 100644
--- a/apps/backend/hub_platform/ai/selectors.py
+++ b/apps/backend/chatballs/ai/selectors.py
@@ -2,12 +2,12 @@ from dataclasses import dataclass
from django.db.models import Count, Prefetch, Q, QuerySet
-from hub_platform.ai.agent_knowledge import knowledge_available_to_channel
-from hub_platform.ai.knowledge_policy import readable_knowledge, writable_knowledge
-from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeCategory
-from hub_platform.channels.models import Channel
-from hub_platform.identity.policy import has_capability_any_scope
-from hub_platform.tenancy.context import TenantContext
+from chatballs.ai.agent_knowledge import knowledge_available_to_channel
+from chatballs.ai.knowledge_policy import readable_knowledge, writable_knowledge
+from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory
+from chatballs.channels.models import Channel
+from chatballs.identity.policy import has_capability_any_scope
+from chatballs.tenancy.context import TenantContext
def agents_for_context(context: TenantContext) -> QuerySet[AIAgent]:
diff --git a/apps/backend/hub_platform/ai/serializers.py b/apps/backend/chatballs/ai/serializers.py
similarity index 95%
rename from apps/backend/hub_platform/ai/serializers.py
rename to apps/backend/chatballs/ai/serializers.py
index 32aad11..ed7b029 100644
--- a/apps/backend/hub_platform/ai/serializers.py
+++ b/apps/backend/chatballs/ai/serializers.py
@@ -1,9 +1,9 @@
-from hub_platform.ai.models import (
+from chatballs.ai.models import (
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
)
-from hub_platform.support_portals.addressing import article_public_url
+from chatballs.support_portals.addressing import article_public_url
def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]:
diff --git a/apps/backend/hub_platform/ai/services.py b/apps/backend/chatballs/ai/services.py
similarity index 94%
rename from apps/backend/hub_platform/ai/services.py
rename to apps/backend/chatballs/ai/services.py
index c93d287..9a23c62 100644
--- a/apps/backend/hub_platform/ai/services.py
+++ b/apps/backend/chatballs/ai/services.py
@@ -3,15 +3,15 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.ai.agent_knowledge import knowledge_available_to_channel
-from hub_platform.ai.models import (
+from chatballs.ai.agent_knowledge import knowledge_available_to_channel
+from chatballs.ai.models import (
AIAgent,
AIAgentStatus,
Knowledge,
)
-from hub_platform.ai.provider_selection import configure_agent_provider
-from hub_platform.channels.models import Channel
-from hub_platform.tenancy.context import TenantContext
+from chatballs.ai.provider_selection import configure_agent_provider
+from chatballs.channels.models import Channel
+from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -188,7 +188,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
locked.status = target_status
locked.lifecycle_version += 1
locked.save(update_fields=["status", "lifecycle_version", "updated_at"])
- from hub_platform.identity.audit import record_audit_event
+ from chatballs.identity.audit import record_audit_event
record_audit_event(
action="ai.agent_status_changed",
@@ -201,7 +201,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
return locked
-from hub_platform.ai.knowledge_services import ( # noqa: E402, F401
+from chatballs.ai.knowledge_services import ( # noqa: E402, F401
KnowledgeInput,
add_attachment,
create_knowledge,
diff --git a/apps/backend/hub_platform/ai/test_agent_attachments.py b/apps/backend/chatballs/ai/test_agent_attachments.py
similarity index 91%
rename from apps/backend/hub_platform/ai/test_agent_attachments.py
rename to apps/backend/chatballs/ai/test_agent_attachments.py
index f6f0036..4b38e74 100644
--- a/apps/backend/hub_platform/ai/test_agent_attachments.py
+++ b/apps/backend/chatballs/ai/test_agent_attachments.py
@@ -2,26 +2,26 @@ import json
from django.test import TestCase
-from hub_platform.ai.agent_knowledge import runtime_portal_articles_for_agent
-from hub_platform.ai.knowledge_categories import create_category
-from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge
-from hub_platform.ai.models import AIAgent, AIAgentStatus, KnowledgeFragment
-from hub_platform.ai.retrieval import lexical_search
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.support_portals.content_services import (
+from chatballs.ai.agent_knowledge import runtime_portal_articles_for_agent
+from chatballs.ai.knowledge_categories import create_category
+from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
+from chatballs.ai.models import AIAgent, AIAgentStatus, KnowledgeFragment
+from chatballs.ai.retrieval import lexical_search
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.support_portals.content_services import (
add_revision,
archive_article,
create_article,
publish_revision,
)
-from hub_platform.support_portals.content_services import (
+from chatballs.support_portals.content_services import (
create_category as create_portal_category,
)
-from hub_platform.support_portals.models import SupportPortal
-from hub_platform.support_portals.portal_services import PortalInput, create_portal
-from hub_platform.support_portals.statuses import ArticleStatus
-from hub_platform.testing import TenantAPIClient, system_tenant_context
+from chatballs.support_portals.models import SupportPortal
+from chatballs.support_portals.portal_services import PortalInput, create_portal
+from chatballs.support_portals.statuses import ArticleStatus
+from chatballs.testing import TenantAPIClient, system_tenant_context
class AgentAttachmentTestCase(TestCase):
diff --git a/apps/backend/hub_platform/ai/test_agent_cards.py b/apps/backend/chatballs/ai/test_agent_cards.py
similarity index 94%
rename from apps/backend/hub_platform/ai/test_agent_cards.py
rename to apps/backend/chatballs/ai/test_agent_cards.py
index 49c7b14..a935938 100644
--- a/apps/backend/hub_platform/ai/test_agent_cards.py
+++ b/apps/backend/chatballs/ai/test_agent_cards.py
@@ -1,18 +1,18 @@
import json
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.ai.models import AIAgent, AIAgentStatus
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import (
+from chatballs.ai.models import AIAgent, AIAgentStatus
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.integrations.models import (
+from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
@@ -70,7 +70,7 @@ class AgentCardCreateTests(AgentCardTestCase):
self.assertEqual(response.status_code, 400)
def test_foreign_group_is_rejected(self) -> None:
- from hub_platform.identity.group_models import EmployeeGroup
+ from chatballs.identity.group_models import EmployeeGroup
other = Organization.objects.create(slug="other-cards", name="Other")
foreign = EmployeeGroup.objects.create(organization=other, name="Чужая")
@@ -177,8 +177,8 @@ class AgentCardUpdateTests(AgentCardTestCase):
def test_knowledge_selection_semantics(self) -> None:
# Пустой список снимает выбор; отсутствие ключа — не трогает.
- from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
- from hub_platform.ai.models import Knowledge
+ from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+ from chatballs.ai.models import Knowledge
knowledge = Knowledge.objects.create(
organization=self.organization,
@@ -202,9 +202,9 @@ class AgentCardActivationTests(AgentCardTestCase):
self.card = self.create_agent().json()["agent"]
def _byok_integration(self) -> Integration:
- from hub_platform.integrations.models import IntegrationProvider
- from hub_platform.integrations.services import IntegrationInput, create_integration
- from hub_platform.testing import system_tenant_context
+ from chatballs.integrations.models import IntegrationProvider
+ from chatballs.integrations.services import IntegrationInput, create_integration
+ from chatballs.testing import system_tenant_context
return create_integration(
context=system_tenant_context(self.organization),
diff --git a/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py b/apps/backend/chatballs/ai/test_agent_knowledge_guards.py
similarity index 90%
rename from apps/backend/hub_platform/ai/test_agent_knowledge_guards.py
rename to apps/backend/chatballs/ai/test_agent_knowledge_guards.py
index 7ecfa1d..e2f05b8 100644
--- a/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py
+++ b/apps/backend/chatballs/ai/test_agent_knowledge_guards.py
@@ -2,16 +2,16 @@ import json
from django.test import TestCase
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge
-from hub_platform.ai.models import AIAgent, AIAgentStatus
-from hub_platform.ai.retrieval import lexical_search, semantic_search
-from hub_platform.ai.runtime import knowledge_catalog
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.testing import TenantAPIClient, system_tenant_context
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
+from chatballs.ai.models import AIAgent, AIAgentStatus
+from chatballs.ai.retrieval import lexical_search, semantic_search
+from chatballs.ai.runtime import knowledge_catalog
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantContext
+from chatballs.testing import TenantAPIClient, system_tenant_context
class AgentKnowledgeAssignmentTests(TestCase):
@@ -52,7 +52,7 @@ class AgentKnowledgeAssignmentTests(TestCase):
)
def _create(self, channel: Channel, knowledge_ids: list[int]) -> AIAgent:
- from hub_platform.ai.services import AgentCreateInput, create_agent
+ from chatballs.ai.services import AgentCreateInput, create_agent
return create_agent(
context=self.context,
diff --git a/apps/backend/hub_platform/ai/test_demo_provider.py b/apps/backend/chatballs/ai/test_demo_provider.py
similarity index 84%
rename from apps/backend/hub_platform/ai/test_demo_provider.py
rename to apps/backend/chatballs/ai/test_demo_provider.py
index b6680cb..7e9dd76 100644
--- a/apps/backend/hub_platform/ai/test_demo_provider.py
+++ b/apps/backend/chatballs/ai/test_demo_provider.py
@@ -2,15 +2,15 @@
from django.test import TestCase
-from hub_platform.ai.provider.base import ChatMessage
-from hub_platform.ai.provider.demo import DemoProvider
-from hub_platform.ai.provider.routing import _provider_from_integration
-from hub_platform.ai.runtime import HANDOFF_TOKEN
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
-from hub_platform.integrations.services import IntegrationInput, create_integration, test_integration
-from hub_platform.testing import system_tenant_context
+from chatballs.ai.provider.base import ChatMessage
+from chatballs.ai.provider.demo import DemoProvider
+from chatballs.ai.provider.routing import _provider_from_integration
+from chatballs.ai.runtime import HANDOFF_TOKEN
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
+from chatballs.integrations.services import IntegrationInput, create_integration, test_integration
+from chatballs.testing import system_tenant_context
KNOWLEDGE = (
"Отвечай только на основе этих знаний:\n"
diff --git a/apps/backend/hub_platform/ai/test_knowledge_api_v2.py b/apps/backend/chatballs/ai/test_knowledge_api_v2.py
similarity index 94%
rename from apps/backend/hub_platform/ai/test_knowledge_api_v2.py
rename to apps/backend/chatballs/ai/test_knowledge_api_v2.py
index b3505b9..8fc8280 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_api_v2.py
+++ b/apps/backend/chatballs/ai/test_knowledge_api_v2.py
@@ -1,8 +1,8 @@
import json
-from hub_platform.ai.knowledge_categories import create_category
-from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
-from hub_platform.testing import TenantAPIClient
+from chatballs.ai.knowledge_categories import create_category
+from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
+from chatballs.testing import TenantAPIClient
class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_bulk.py b/apps/backend/chatballs/ai/test_knowledge_bulk.py
similarity index 93%
rename from apps/backend/hub_platform/ai/test_knowledge_bulk.py
rename to apps/backend/chatballs/ai/test_knowledge_bulk.py
index 2127795..53e4a9c 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_bulk.py
+++ b/apps/backend/chatballs/ai/test_knowledge_bulk.py
@@ -2,12 +2,12 @@ import json
from django.test import TestCase
-from hub_platform.ai.knowledge_categories import create_category
-from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge
-from hub_platform.ai.models import AIAgent, AIAgentStatus
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.testing import TenantAPIClient, system_tenant_context
+from chatballs.ai.knowledge_categories import create_category
+from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
+from chatballs.ai.models import AIAgent, AIAgentStatus
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.testing import TenantAPIClient, system_tenant_context
class KnowledgeBulkApiTests(TestCase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_category_api.py b/apps/backend/chatballs/ai/test_knowledge_category_api.py
similarity index 95%
rename from apps/backend/hub_platform/ai/test_knowledge_category_api.py
rename to apps/backend/chatballs/ai/test_knowledge_category_api.py
index a78a7be..b85e04a 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_category_api.py
+++ b/apps/backend/chatballs/ai/test_knowledge_category_api.py
@@ -1,8 +1,8 @@
import json
-from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
-from hub_platform.ai.models import KnowledgeCategory
-from hub_platform.testing import TenantAPIClient
+from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
+from chatballs.ai.models import KnowledgeCategory
+from chatballs.testing import TenantAPIClient
class KnowledgeCategoryApiTests(KnowledgePolicyTestBase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_category_migration.py b/apps/backend/chatballs/ai/test_knowledge_category_migration.py
similarity index 100%
rename from apps/backend/hub_platform/ai/test_knowledge_category_migration.py
rename to apps/backend/chatballs/ai/test_knowledge_category_migration.py
diff --git a/apps/backend/hub_platform/ai/test_knowledge_detail_payload.py b/apps/backend/chatballs/ai/test_knowledge_detail_payload.py
similarity index 90%
rename from apps/backend/hub_platform/ai/test_knowledge_detail_payload.py
rename to apps/backend/chatballs/ai/test_knowledge_detail_payload.py
index 22bde00..9c2082e 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_detail_payload.py
+++ b/apps/backend/chatballs/ai/test_knowledge_detail_payload.py
@@ -3,8 +3,8 @@ import json
from django.test import TestCase
from rest_framework.test import APIClient
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
class KnowledgeDetailPayloadTests(TestCase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_hierarchy.py b/apps/backend/chatballs/ai/test_knowledge_hierarchy.py
similarity index 95%
rename from apps/backend/hub_platform/ai/test_knowledge_hierarchy.py
rename to apps/backend/chatballs/ai/test_knowledge_hierarchy.py
index b2c3e53..dedfa49 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_hierarchy.py
+++ b/apps/backend/chatballs/ai/test_knowledge_hierarchy.py
@@ -2,19 +2,19 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
-from hub_platform.ai.knowledge_categories import (
+from chatballs.ai.knowledge_categories import (
create_category,
delete_category,
ensure_uncategorized_category,
move_category,
rename_category,
)
-from hub_platform.ai.knowledge_types import (
+from chatballs.ai.knowledge_types import (
UNCATEGORIZED_CATEGORY_NAME,
)
-from hub_platform.ai.models import Knowledge, KnowledgeCategory
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.context import TenantContext
+from chatballs.ai.models import Knowledge, KnowledgeCategory
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantContext
class KnowledgeCategoryTests(TestCase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_import_v2.py b/apps/backend/chatballs/ai/test_knowledge_import_v2.py
similarity index 91%
rename from apps/backend/hub_platform/ai/test_knowledge_import_v2.py
rename to apps/backend/chatballs/ai/test_knowledge_import_v2.py
index d37679d..a816644 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_import_v2.py
+++ b/apps/backend/chatballs/ai/test_knowledge_import_v2.py
@@ -2,13 +2,13 @@ import json
from django.test import TestCase
-from hub_platform.ai.knowledge_categories import create_category
-from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
-from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
-from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeCategory
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.testing import TenantAPIClient, system_tenant_context
+from chatballs.ai.knowledge_categories import create_category
+from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
+from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
+from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeCategory
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.testing import TenantAPIClient, system_tenant_context
class KnowledgeMetadataImportTests(TestCase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_policy.py b/apps/backend/chatballs/ai/test_knowledge_policy.py
similarity index 93%
rename from apps/backend/hub_platform/ai/test_knowledge_policy.py
rename to apps/backend/chatballs/ai/test_knowledge_policy.py
index 8bc9b42..74c935c 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_policy.py
+++ b/apps/backend/chatballs/ai/test_knowledge_policy.py
@@ -1,14 +1,14 @@
from django.core.exceptions import PermissionDenied
-from hub_platform.ai.knowledge_policy import (
+from chatballs.ai.knowledge_policy import (
employee_can_manage_categories,
employee_can_read_knowledge,
employee_can_write_knowledge,
require_knowledge_create,
)
-from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
-from hub_platform.ai.models import Knowledge
-from hub_platform.ai.selectors import (
+from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
+from chatballs.ai.models import Knowledge
+from chatballs.ai.selectors import (
knowledge_for_employee,
writable_knowledge_for_employee,
)
diff --git a/apps/backend/hub_platform/ai/test_knowledge_policy_api.py b/apps/backend/chatballs/ai/test_knowledge_policy_api.py
similarity index 93%
rename from apps/backend/hub_platform/ai/test_knowledge_policy_api.py
rename to apps/backend/chatballs/ai/test_knowledge_policy_api.py
index 3267b4a..4d89429 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_policy_api.py
+++ b/apps/backend/chatballs/ai/test_knowledge_policy_api.py
@@ -1,9 +1,9 @@
import json
-from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
-from hub_platform.ai.models import AIAgent
-from hub_platform.channels.models import Channel
-from hub_platform.testing import TenantAPIClient
+from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
+from chatballs.ai.models import AIAgent
+from chatballs.channels.models import Channel
+from chatballs.testing import TenantAPIClient
class KnowledgePolicyApiTests(KnowledgePolicyTestBase):
diff --git a/apps/backend/hub_platform/ai/test_knowledge_selectors.py b/apps/backend/chatballs/ai/test_knowledge_selectors.py
similarity index 92%
rename from apps/backend/hub_platform/ai/test_knowledge_selectors.py
rename to apps/backend/chatballs/ai/test_knowledge_selectors.py
index 4a8070c..c0cbfb5 100644
--- a/apps/backend/hub_platform/ai/test_knowledge_selectors.py
+++ b/apps/backend/chatballs/ai/test_knowledge_selectors.py
@@ -1,8 +1,8 @@
-from hub_platform.ai.knowledge_categories import create_category
-from hub_platform.ai.knowledge_policy import knowledge_is_available_to_agent
-from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
-from hub_platform.ai.models import AIAgent, Knowledge
-from hub_platform.ai.selectors import (
+from chatballs.ai.knowledge_categories import create_category
+from chatballs.ai.knowledge_policy import knowledge_is_available_to_agent
+from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
+from chatballs.ai.models import AIAgent, Knowledge
+from chatballs.ai.selectors import (
KnowledgeFilters,
agent_for_employee,
agents_for_employee,
@@ -11,7 +11,7 @@ from hub_platform.ai.selectors import (
knowledge_available_to_agent,
knowledge_for_employee,
)
-from hub_platform.channels.models import Channel
+from chatballs.channels.models import Channel
class KnowledgeSelectorTests(KnowledgePolicyTestBase):
diff --git a/apps/backend/hub_platform/ai/test_provider_modes.py b/apps/backend/chatballs/ai/test_provider_modes.py
similarity index 87%
rename from apps/backend/hub_platform/ai/test_provider_modes.py
rename to apps/backend/chatballs/ai/test_provider_modes.py
index 259c931..8d61355 100644
--- a/apps/backend/hub_platform/ai/test_provider_modes.py
+++ b/apps/backend/chatballs/ai/test_provider_modes.py
@@ -3,25 +3,25 @@ from unittest import mock
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
-from hub_platform.ai.invocation import invoke_chat
-from hub_platform.ai.provider.base import ChatMessage, ChatResult, ProviderError
-from hub_platform.ai.provider.custom import CustomProvider
-from hub_platform.ai.provider.factory import get_provider
-from hub_platform.ai.provider.local import LocalProvider
-from hub_platform.ai.provider.openrouter import OpenRouterProvider
-from hub_platform.ai.provider.routing import (
+from chatballs.ai.invocation import invoke_chat
+from chatballs.ai.provider.base import ChatMessage, ChatResult, ProviderError
+from chatballs.ai.provider.custom import CustomProvider
+from chatballs.ai.provider.factory import get_provider
+from chatballs.ai.provider.local import LocalProvider
+from chatballs.ai.provider.openrouter import OpenRouterProvider
+from chatballs.ai.provider.routing import (
IntegrationNotConfigured,
resolve_model,
resolve_provider_and_model,
)
-from hub_platform.ai.provider_selection import configure_agent_provider
-from hub_platform.ai.tests import make_channel_with_agent
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.integrations.models import Integration, IntegrationProvider
-from hub_platform.integrations.services import IntegrationInput, create_integration
-from hub_platform.products.models import Product
-from hub_platform.testing import system_tenant_context
+from chatballs.ai.provider_selection import configure_agent_provider
+from chatballs.ai.tests import make_channel_with_agent
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations.models import Integration, IntegrationProvider
+from chatballs.integrations.services import IntegrationInput, create_integration
+from chatballs.products.models import Product
+from chatballs.testing import system_tenant_context
class ProviderModeTests(TestCase):
@@ -80,7 +80,7 @@ class ProviderModeTests(TestCase):
self.assertIsInstance(provider, provider_type)
self.assertEqual(model, "runtime-model")
- @override_settings(CUS_AI_PROVIDER="")
+ @override_settings(CHATBALLS_AI_PROVIDER="")
def test_byok_uses_agent_integration(self) -> None:
integration = self._link_integration()
@@ -88,7 +88,7 @@ class ProviderModeTests(TestCase):
self.assertIsInstance(provider, CustomProvider)
self.assertEqual(provider.api_key, integration.secret)
- @override_settings(CUS_AI_PROVIDER="")
+ @override_settings(CHATBALLS_AI_PROVIDER="")
def test_missing_integration_raises_integration_not_configured(self) -> None:
with self.assertRaises(IntegrationNotConfigured):
get_provider(channel=self.channel)
@@ -98,16 +98,16 @@ class ProviderModeTests(TestCase):
# ловит ProviderError (индексация без эмбеддингов, handoff в диалогах).
self.assertTrue(issubclass(IntegrationNotConfigured, ProviderError))
- @override_settings(CUS_AI_PROVIDER="test")
+ @override_settings(CHATBALLS_AI_PROVIDER="test")
def test_test_provider_setting_gives_local_provider(self) -> None:
self.assertIsInstance(get_provider(channel=self.channel), LocalProvider)
def test_openrouter_from_integration_removed(self) -> None:
- from hub_platform.ai.provider import factory
+ from chatballs.ai.provider import factory
self.assertFalse(hasattr(factory, "_openrouter_from_integration"))
- @override_settings(CUS_AI_PROVIDER="")
+ @override_settings(CHATBALLS_AI_PROVIDER="")
def test_default_model_read_in_runtime(self) -> None:
self._link_integration(default_model="integration-model")
with mock.patch.object(
diff --git a/apps/backend/hub_platform/ai/test_storage_isolation.py b/apps/backend/chatballs/ai/test_storage_isolation.py
similarity index 86%
rename from apps/backend/hub_platform/ai/test_storage_isolation.py
rename to apps/backend/chatballs/ai/test_storage_isolation.py
index 1d3bd35..01ddaf1 100644
--- a/apps/backend/hub_platform/ai/test_storage_isolation.py
+++ b/apps/backend/chatballs/ai/test_storage_isolation.py
@@ -5,13 +5,13 @@ from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.ai.models import Knowledge
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.models import OrganizationStorageUsage
-from hub_platform.testing import TenantAPIClient, system_tenant_context
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.ai.models import Knowledge
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.models import OrganizationStorageUsage
+from chatballs.testing import TenantAPIClient, system_tenant_context
_MEDIA_ROOT = tempfile.mkdtemp(prefix="c04-storage-isolation-")
diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/chatballs/ai/tests.py
similarity index 87%
rename from apps/backend/hub_platform/ai/tests.py
rename to apps/backend/chatballs/ai/tests.py
index bfa2a23..c27ebe6 100644
--- a/apps/backend/hub_platform/ai/tests.py
+++ b/apps/backend/chatballs/ai/tests.py
@@ -3,14 +3,14 @@ import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
-from hub_platform.testing import TenantAPIClient as APIClient, system_tenant_context
+from chatballs.testing import TenantAPIClient as APIClient, system_tenant_context
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
-from hub_platform.products.models import Product
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
+from chatballs.products.models import Product
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
@@ -45,7 +45,7 @@ class AIAgentInvariantTests(TestCase):
def test_channel_has_at_most_one_agent(self) -> None:
from django.core.exceptions import ValidationError
- from hub_platform.ai.services import AgentCreateInput, create_agent
+ from chatballs.ai.services import AgentCreateInput, create_agent
channel, _ = make_channel_with_agent(
self.organization, code="firepage-sales", name="FirePage — продажи",
@@ -263,7 +263,7 @@ class KnowledgeImportTests(TestCase):
class PiiRedactionTests(TestCase):
def test_redacts_email_phone_and_long_numbers(self) -> None:
- from hub_platform.ai.pii import redact
+ from chatballs.ai.pii import redact
cleaned = redact("Пишите a.kotova@edevs.tech, тел +7 916 245 14 02, карта 4111 1111 1111 1111")
self.assertNotIn("a.kotova@edevs.tech", cleaned)
@@ -274,8 +274,8 @@ class PiiRedactionTests(TestCase):
class ResilienceTests(TestCase):
def test_retries_then_succeeds(self) -> None:
- from hub_platform.ai.provider.base import ProviderError
- from hub_platform.ai.provider.resilience import call_with_resilience
+ from chatballs.ai.provider.base import ProviderError
+ from chatballs.ai.provider.resilience import call_with_resilience
calls = {"n": 0}
@@ -290,8 +290,8 @@ class ResilienceTests(TestCase):
self.assertEqual(calls["n"], 2)
def test_circuit_breaker_opens_after_threshold(self) -> None:
- from hub_platform.ai.provider.base import ProviderError
- from hub_platform.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
+ from chatballs.ai.provider.base import ProviderError
+ from chatballs.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
@@ -307,16 +307,16 @@ class ResilienceTests(TestCase):
class ProviderFactoryTests(TestCase):
def test_local_provider_in_tests(self) -> None:
- from hub_platform.ai.provider.factory import get_provider
- from hub_platform.ai.provider.local import LocalProvider
+ from chatballs.ai.provider.factory import get_provider
+ from chatballs.ai.provider.local import LocalProvider
self.assertIsInstance(get_provider(), LocalProvider)
- @override_settings(DEBUG=False, TESTING=False, CUS_AI_PROVIDER="test")
+ @override_settings(DEBUG=False, TESTING=False, CHATBALLS_AI_PROVIDER="test")
def test_local_provider_forbidden_in_production(self) -> None:
from django.core.exceptions import ImproperlyConfigured
- from hub_platform.ai.provider.factory import get_provider
+ from chatballs.ai.provider.factory import get_provider
with self.assertRaises(ImproperlyConfigured):
get_provider()
@@ -332,9 +332,9 @@ class ChatInvocationTests(TestCase):
)
def test_chat_records_invocation_with_cost(self) -> None:
- from hub_platform.ai.invocation import invoke_chat
- from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
- from hub_platform.ai.provider.base import ChatMessage
+ from chatballs.ai.invocation import invoke_chat
+ from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
+ from chatballs.ai.provider.base import ChatMessage
result = invoke_chat(
channel=self.channel,
@@ -347,7 +347,7 @@ class ChatInvocationTests(TestCase):
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
self.assertGreater(invocation.total_tokens, 0)
# Технический учёт стоимости (ADR-HUB-0042 §2): считается по прайсу модели.
- from hub_platform.ai import pricing
+ from chatballs.ai import pricing
self.assertEqual(
invocation.cost_micros,
@@ -359,8 +359,8 @@ class ChatInvocationTests(TestCase):
def test_pii_is_redacted_before_reaching_provider(self) -> None:
from unittest import mock
- from hub_platform.ai.invocation import invoke_chat
- from hub_platform.ai.provider.base import ChatMessage, ChatResult
+ from chatballs.ai.invocation import invoke_chat
+ from chatballs.ai.provider.base import ChatMessage, ChatResult
captured = {}
@@ -372,7 +372,7 @@ class ChatInvocationTests(TestCase):
def embed(self, *, texts, model): # pragma: no cover
return []
- with mock.patch("hub_platform.ai.invocation.get_provider", return_value=_Capturing()):
+ with mock.patch("chatballs.ai.invocation.get_provider", return_value=_Capturing()):
invoke_chat(
channel=self.channel,
messages=[ChatMessage(role="user", content="email me a@b.com")],
@@ -382,10 +382,10 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content)
def test_limit_blocks_and_records(self) -> None:
- from hub_platform.ai import limits as ai_limits
- from hub_platform.ai.invocation import invoke_chat
- from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
- from hub_platform.ai.provider.base import ChatMessage
+ from chatballs.ai import limits as ai_limits
+ from chatballs.ai.invocation import invoke_chat
+ from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
+ from chatballs.ai.provider.base import ChatMessage
self.agent.limits = {"dailyCostUsd": 1}
self.agent.save(update_fields=["limits"])
@@ -400,9 +400,9 @@ class ChatInvocationTests(TestCase):
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
def test_invocation_records_used_fragment_ids(self) -> None:
- from hub_platform.ai.invocation import invoke_chat
- from hub_platform.ai.models import LlmInvocation
- from hub_platform.ai.provider.base import ChatMessage
+ from chatballs.ai.invocation import invoke_chat
+ from chatballs.ai.models import LlmInvocation
+ from chatballs.ai.provider.base import ChatMessage
invoke_chat(
channel=self.channel,
@@ -416,7 +416,7 @@ class ChatInvocationTests(TestCase):
class ChunkingTests(TestCase):
def test_packs_paragraphs_into_chunks(self) -> None:
- from hub_platform.ai.chunking import chunk_text
+ from chatballs.ai.chunking import chunk_text
text = "\n\n".join(["paragraph " + str(i) + " " + "x" * 200 for i in range(10)])
chunks = chunk_text(text, max_chars=500)
@@ -426,17 +426,17 @@ class ChunkingTests(TestCase):
class TextExtractionTests(TestCase):
def test_extracts_plain_text(self) -> None:
- from hub_platform.ai.extraction import extract_text
+ from chatballs.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.md", content_type="", data="# Заголовок".encode()), "# Заголовок")
def test_unknown_format_returns_empty(self) -> None:
- from hub_platform.ai.extraction import extract_text
+ from chatballs.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.bin", content_type="application/octet-stream", data=b"\x00\x01"), "")
def test_broken_pdf_is_not_fatal(self) -> None:
- from hub_platform.ai.extraction import extract_text
+ from chatballs.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.pdf", content_type="application/pdf", data=b"not a pdf"), "")
@@ -449,7 +449,7 @@ class KnowledgeRetrievalTests(TestCase):
self.organization, code="firepage-sales", name="FirePage — продажи",
product=Product.objects.get(code="firepage"),
)
- from hub_platform.ai.services import KnowledgeInput, create_knowledge
+ from chatballs.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
context=system_tenant_context(self.organization),
@@ -468,21 +468,21 @@ class KnowledgeRetrievalTests(TestCase):
self.assertTrue(all(fragment.embedding is not None for fragment in fragments))
def test_retriever_returns_agent_scoped_fragments(self) -> None:
- from hub_platform.ai.retrieval import KnowledgeRetriever
+ from chatballs.ai.retrieval import KnowledgeRetriever
results = KnowledgeRetriever().retrieve(agent=self.agent, query="refund", limit=5)
self.assertGreaterEqual(len(results), 1)
self.assertLessEqual(len(results), 5)
def test_retriever_skips_unselected_and_disabled_knowledge(self) -> None:
- from hub_platform.ai.retrieval import lexical_search
+ from chatballs.ai.retrieval import lexical_search
self.knowledge.is_enabled = False
self.knowledge.save(update_fields=["is_enabled"])
self.assertEqual(lexical_search(self.agent, "refund", limit=5), [])
def test_lexical_search_matches_content(self) -> None:
- from hub_platform.ai.retrieval import lexical_search
+ from chatballs.ai.retrieval import lexical_search
results = lexical_search(self.agent, "Delivery", limit=5)
self.assertTrue(any("delivery" in fragment.content.lower() for fragment in results))
@@ -500,7 +500,7 @@ class AgentRuntimeTests(TestCase):
self.agent.tone = "Коротко."
self.agent.instructions = "Отвечай только по знаниям."
self.agent.save()
- from hub_platform.ai.services import KnowledgeInput, create_knowledge
+ from chatballs.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
context=system_tenant_context(self.organization),
@@ -509,22 +509,22 @@ class AgentRuntimeTests(TestCase):
self.agent.knowledge_items.add(self.knowledge)
def test_system_prompt_joins_three_parts_in_order(self) -> None:
- from hub_platform.ai.runtime import agent_system_prompt
+ from chatballs.ai.runtime import agent_system_prompt
prompt = agent_system_prompt(self.agent)
self.assertEqual(prompt.index("Ты — ассистент"), 0)
self.assertLess(prompt.index("Коротко."), prompt.index("Отвечай только"))
def test_knowledge_catalog_lists_titles_and_attachment_links(self) -> None:
- from hub_platform.ai.runtime import knowledge_catalog
+ from chatballs.ai.runtime import knowledge_catalog
catalog = knowledge_catalog(self.agent)
self.assertIn("FAQ", catalog)
self.assertIn("Возвраты", catalog)
def test_agent_turn_returns_reply_and_fragments(self) -> None:
- from hub_platform.ai.models import LlmInvocation
- from hub_platform.ai.runtime import run_agent_turn
+ from chatballs.ai.models import LlmInvocation
+ from chatballs.ai.runtime import run_agent_turn
result = run_agent_turn(agent=self.agent, message="refund")
@@ -535,8 +535,8 @@ class AgentRuntimeTests(TestCase):
self.assertTrue(invocation.used_fragment_ids)
def test_channel_turn_requires_active_agent(self) -> None:
- from hub_platform.ai.provider.base import ProviderError
- from hub_platform.channels.runtime import run_channel_turn
+ from chatballs.ai.provider.base import ProviderError
+ from chatballs.channels.runtime import run_channel_turn
self.agent.status = AIAgentStatus.DISABLED
self.agent.save(update_fields=["status"])
diff --git a/apps/backend/hub_platform/ai/urls.py b/apps/backend/chatballs/ai/urls.py
similarity index 96%
rename from apps/backend/hub_platform/ai/urls.py
rename to apps/backend/chatballs/ai/urls.py
index e07c373..48a9219 100644
--- a/apps/backend/hub_platform/ai/urls.py
+++ b/apps/backend/chatballs/ai/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.ai import bulk_views, category_views, views
+from chatballs.ai import bulk_views, category_views, views
urlpatterns = [
# agentId — id AIAgent, как и в knowledge/bulk/agent/ (CRUD агентов — /agents/).
diff --git a/apps/backend/hub_platform/ai/views.py b/apps/backend/chatballs/ai/views.py
similarity index 92%
rename from apps/backend/hub_platform/ai/views.py
rename to apps/backend/chatballs/ai/views.py
index a42950f..e2c476f 100644
--- a/apps/backend/hub_platform/ai/views.py
+++ b/apps/backend/chatballs/ai/views.py
@@ -4,31 +4,31 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.ai.api_errors import validation_error_response
-from hub_platform.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
-from hub_platform.ai.knowledge_import import import_knowledge_documents
-from hub_platform.ai.knowledge_policy import (
+from chatballs.ai.api_errors import validation_error_response
+from chatballs.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
+from chatballs.ai.knowledge_import import import_knowledge_documents
+from chatballs.ai.knowledge_policy import (
employee_can_write_knowledge,
require_knowledge_create,
)
-from hub_platform.ai.knowledge_services import (
+from chatballs.ai.knowledge_services import (
add_attachment,
create_knowledge,
delete_attachment,
delete_knowledge,
update_knowledge,
)
-from hub_platform.ai.models import Knowledge
-from hub_platform.ai.selectors import (
+from chatballs.ai.models import Knowledge
+from chatballs.ai.selectors import (
apply_knowledge_filters,
knowledge_for_context,
knowledge_item_for_context,
writable_knowledge_item_for_employee,
)
-from hub_platform.ai.serializers import attachment_payload, knowledge_payload
-from hub_platform.api.permissions import HasCapability
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.models import AuditEvent
+from chatballs.ai.serializers import attachment_payload, knowledge_payload
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.models import AuditEvent
_validation_error = validation_error_response
diff --git a/apps/backend/hub_platform/api/__init__.py b/apps/backend/chatballs/api/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/api/__init__.py
rename to apps/backend/chatballs/api/__init__.py
diff --git a/apps/backend/hub_platform/api/exceptions.py b/apps/backend/chatballs/api/exceptions.py
similarity index 100%
rename from apps/backend/hub_platform/api/exceptions.py
rename to apps/backend/chatballs/api/exceptions.py
diff --git a/apps/backend/hub_platform/api/permissions.py b/apps/backend/chatballs/api/permissions.py
similarity index 91%
rename from apps/backend/hub_platform/api/permissions.py
rename to apps/backend/chatballs/api/permissions.py
index 1607a5d..2c98a60 100644
--- a/apps/backend/hub_platform/api/permissions.py
+++ b/apps/backend/chatballs/api/permissions.py
@@ -3,7 +3,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView
-from hub_platform.identity.policy import has_capability_any_scope
+from chatballs.identity.policy import has_capability_any_scope
class HasCapability(BasePermission):
@@ -34,4 +34,4 @@ class CloudDeliveryOnly(BasePermission):
message = "This operation is available only in Chatballs Cloud"
def has_permission(self, request: Request, view: APIView) -> bool:
- return settings.CUS_DELIVERY_MODE == "CLOUD"
+ return settings.CHATBALLS_DELIVERY_MODE == "CLOUD"
diff --git a/apps/backend/hub_platform/__init__.py b/apps/backend/chatballs/calls/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/__init__.py
rename to apps/backend/chatballs/calls/__init__.py
diff --git a/apps/backend/hub_platform/calls/admin.py b/apps/backend/chatballs/calls/admin.py
similarity index 91%
rename from apps/backend/hub_platform/calls/admin.py
rename to apps/backend/chatballs/calls/admin.py
index f9a281d..dafbf44 100644
--- a/apps/backend/hub_platform/calls/admin.py
+++ b/apps/backend/chatballs/calls/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from hub_platform.calls.models import CallInvite, CallMetric, CallParticipant, CallSession
+from chatballs.calls.models import CallInvite, CallMetric, CallParticipant, CallSession
@admin.register(CallSession)
diff --git a/apps/backend/hub_platform/calls/apps.py b/apps/backend/chatballs/calls/apps.py
similarity index 58%
rename from apps/backend/hub_platform/calls/apps.py
rename to apps/backend/chatballs/calls/apps.py
index 5a96d9d..ac80544 100644
--- a/apps/backend/hub_platform/calls/apps.py
+++ b/apps/backend/chatballs/calls/apps.py
@@ -3,8 +3,8 @@ from django.apps import AppConfig
class CallsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.calls"
+ name = "chatballs.calls"
verbose_name = "P2P calls"
def ready(self) -> None:
- from hub_platform.calls import event_handlers # noqa: F401 (register outbox handlers)
+ from chatballs.calls import event_handlers # noqa: F401 (register outbox handlers)
diff --git a/apps/backend/hub_platform/calls/consumers.py b/apps/backend/chatballs/calls/consumers.py
similarity index 94%
rename from apps/backend/hub_platform/calls/consumers.py
rename to apps/backend/chatballs/calls/consumers.py
index 3ed6536..bdbe2fe 100644
--- a/apps/backend/hub_platform/calls/consumers.py
+++ b/apps/backend/chatballs/calls/consumers.py
@@ -14,13 +14,13 @@ import logging
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
-from hub_platform.calls import signaling
-from hub_platform.calls.errors import CallTokenError
-from hub_platform.calls.models import TERMINAL_CALL_STATUSES
-from hub_platform.calls.permissions import staff_call_access_valid
-from hub_platform.calls.services import authorize_call_access_context
-from hub_platform.calls.models import ParticipantSide
-from hub_platform.tenancy.database import run_tenant_operation
+from chatballs.calls import signaling
+from chatballs.calls.errors import CallTokenError
+from chatballs.calls.models import TERMINAL_CALL_STATUSES
+from chatballs.calls.permissions import staff_call_access_valid
+from chatballs.calls.services import authorize_call_access_context
+from chatballs.calls.models import ParticipantSide
+from chatballs.tenancy.database import run_tenant_operation
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/calls/errors.py b/apps/backend/chatballs/calls/errors.py
similarity index 100%
rename from apps/backend/hub_platform/calls/errors.py
rename to apps/backend/chatballs/calls/errors.py
diff --git a/apps/backend/hub_platform/calls/event_handlers.py b/apps/backend/chatballs/calls/event_handlers.py
similarity index 84%
rename from apps/backend/hub_platform/calls/event_handlers.py
rename to apps/backend/chatballs/calls/event_handlers.py
index b3b0a97..3ed7dc9 100644
--- a/apps/backend/hub_platform/calls/event_handlers.py
+++ b/apps/backend/chatballs/calls/event_handlers.py
@@ -10,13 +10,13 @@ import logging
from django.conf import settings
from django.db import transaction
-from hub_platform.calls.lifecycle import transition_call
-from hub_platform.calls.models import CallInvite, CallSession, CallStatus, InviteDeliveryStatus
-from hub_platform.calls.services import CALL_INVITE_SEND
-from hub_platform.calls.tokens import issue_invite_token
-from hub_platform.conversations import transports
-from hub_platform.events.handlers import register
-from hub_platform.tenancy.context import TenantContext
+from chatballs.calls.lifecycle import transition_call
+from chatballs.calls.models import CallInvite, CallSession, CallStatus, InviteDeliveryStatus
+from chatballs.calls.services import CALL_INVITE_SEND
+from chatballs.calls.tokens import issue_invite_token
+from chatballs.conversations import transports
+from chatballs.events.handlers import register
+from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
@@ -51,7 +51,7 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non
invite.save(update_fields=["token_hash"])
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку."
- url = f"{settings.CUS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}"
+ url = f"{settings.CHATBALLS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}"
sent = transports.send_call_invite(
call.delivery_connection,
chat_id=call.conversation.external_chat_id,
diff --git a/apps/backend/hub_platform/calls/lifecycle.py b/apps/backend/chatballs/calls/lifecycle.py
similarity index 96%
rename from apps/backend/hub_platform/calls/lifecycle.py
rename to apps/backend/chatballs/calls/lifecycle.py
index f5dc4dd..7fca6de 100644
--- a/apps/backend/hub_platform/calls/lifecycle.py
+++ b/apps/backend/chatballs/calls/lifecycle.py
@@ -3,16 +3,16 @@ import re
from django.db import transaction
from django.utils import timezone
-from hub_platform.calls.errors import CallInvalidTransition
-from hub_platform.calls.models import (
+from chatballs.calls.errors import CallInvalidTransition
+from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
CallEndedBy,
CallSession,
CallStatus,
ParticipantSide,
)
-from hub_platform.conversations.models import Message, MessageAuthor
-from hub_platform.tenancy.context import TenantContext
+from chatballs.conversations.models import Message, MessageAuthor
+from chatballs.tenancy.context import TenantContext
ALLOWED_TRANSITIONS = {
CallStatus.REQUESTED: {
diff --git a/apps/backend/hub_platform/calls/maintenance.py b/apps/backend/chatballs/calls/maintenance.py
similarity index 88%
rename from apps/backend/hub_platform/calls/maintenance.py
rename to apps/backend/chatballs/calls/maintenance.py
index a762293..cda6e49 100644
--- a/apps/backend/hub_platform/calls/maintenance.py
+++ b/apps/backend/chatballs/calls/maintenance.py
@@ -6,9 +6,9 @@ from datetime import timedelta
from django.conf import settings
from django.utils import timezone
-from hub_platform.calls.errors import CallInvalidTransition
-from hub_platform.calls.lifecycle import transition_call
-from hub_platform.calls.models import CallEndedBy, CallSession, CallStatus
+from chatballs.calls.errors import CallInvalidTransition
+from chatballs.calls.lifecycle import transition_call
+from chatballs.calls.models import CallEndedBy, CallSession, CallStatus
def expire_stale_calls(context) -> int:
@@ -31,7 +31,7 @@ def expire_stale_calls(context) -> int:
continue # состояние сменилось между выборкой и переходом
# Принятый звонок без установленного соединения дольше grace period — FAILED.
- connect_deadline = now - timedelta(seconds=settings.CUS_CALL_CONNECT_GRACE_SECONDS)
+ connect_deadline = now - timedelta(seconds=settings.CHATBALLS_CALL_CONNECT_GRACE_SECONDS)
stuck = CallSession.objects.filter(
organization=context.organization,
status__in=[CallStatus.ACCEPTED, CallStatus.CONNECTING],
@@ -51,7 +51,7 @@ def expire_stale_calls(context) -> int:
# Активный звонок с участником, не восстановившимся после обрыва (SPEC §5:
# временный обрыв → reconnecting, после grace period — FAILED).
- reconnect_deadline = now - timedelta(seconds=settings.CUS_CALL_RECONNECT_GRACE_SECONDS)
+ reconnect_deadline = now - timedelta(seconds=settings.CHATBALLS_CALL_RECONNECT_GRACE_SECONDS)
dropped = (
CallSession.objects.filter(
organization=context.organization,
diff --git a/apps/backend/hub_platform/calls/metrics.py b/apps/backend/chatballs/calls/metrics.py
similarity index 94%
rename from apps/backend/hub_platform/calls/metrics.py
rename to apps/backend/chatballs/calls/metrics.py
index 0ddd43f..5dc5266 100644
--- a/apps/backend/hub_platform/calls/metrics.py
+++ b/apps/backend/chatballs/calls/metrics.py
@@ -1,12 +1,12 @@
from __future__ import annotations
-from hub_platform.calls.models import (
+from chatballs.calls.models import (
CallConnectionType,
CallMetric,
CallSession,
ParticipantSide,
)
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
_ALLOWED_CANDIDATE_TYPES = {"host", "srflx", "prflx", "relay"}
_MAX_ROUND_TRIP_MS = 60_000
diff --git a/apps/backend/hub_platform/calls/migrations/0001_initial.py b/apps/backend/chatballs/calls/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/calls/migrations/0001_initial.py
rename to apps/backend/chatballs/calls/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/calls/migrations/0002_callmetric.py b/apps/backend/chatballs/calls/migrations/0002_callmetric.py
similarity index 100%
rename from apps/backend/hub_platform/calls/migrations/0002_callmetric.py
rename to apps/backend/chatballs/calls/migrations/0002_callmetric.py
diff --git a/apps/backend/hub_platform/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py b/apps/backend/chatballs/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py
rename to apps/backend/chatballs/calls/migrations/0003_callinvite_organization_callmetric_organization_and_more.py
diff --git a/apps/backend/hub_platform/calls/migrations/0004_alter_callinvite_organization_and_more.py b/apps/backend/chatballs/calls/migrations/0004_alter_callinvite_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/calls/migrations/0004_alter_callinvite_organization_and_more.py
rename to apps/backend/chatballs/calls/migrations/0004_alter_callinvite_organization_and_more.py
diff --git a/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py b/apps/backend/chatballs/calls/migrations/0005_callsession_kind.py
similarity index 100%
rename from apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py
rename to apps/backend/chatballs/calls/migrations/0005_callsession_kind.py
diff --git a/apps/backend/hub_platform/calls/migrations/__init__.py b/apps/backend/chatballs/calls/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/calls/migrations/__init__.py
rename to apps/backend/chatballs/calls/migrations/__init__.py
diff --git a/apps/backend/hub_platform/calls/models.py b/apps/backend/chatballs/calls/models.py
similarity index 99%
rename from apps/backend/hub_platform/calls/models.py
rename to apps/backend/chatballs/calls/models.py
index 74644b2..3a64e92 100644
--- a/apps/backend/hub_platform/calls/models.py
+++ b/apps/backend/chatballs/calls/models.py
@@ -3,7 +3,7 @@ import uuid
from django.conf import settings
from django.db import models
-from hub_platform.tenancy.models import TenantRelationModel
+from chatballs.tenancy.models import TenantRelationModel
class CallStatus(models.TextChoices):
diff --git a/apps/backend/hub_platform/calls/permissions.py b/apps/backend/chatballs/calls/permissions.py
similarity index 76%
rename from apps/backend/hub_platform/calls/permissions.py
rename to apps/backend/chatballs/calls/permissions.py
index 1523abb..fec2ea3 100644
--- a/apps/backend/hub_platform/calls/permissions.py
+++ b/apps/backend/chatballs/calls/permissions.py
@@ -1,15 +1,15 @@
-from hub_platform.calls.errors import CallAccessDenied
-from hub_platform.calls.models import CallSession
-from hub_platform.conversations.models import Conversation
-from hub_platform.identity.models import OrganizationMembership
-from hub_platform.identity.policy import require_capability
-from hub_platform.tenancy.context import TenantContext
+from chatballs.calls.errors import CallAccessDenied
+from chatballs.calls.models import CallSession
+from chatballs.conversations.models import Conversation
+from chatballs.identity.models import OrganizationMembership
+from chatballs.identity.policy import require_capability
+from chatballs.tenancy.context import TenantContext
def ensure_conversation_call_access(*, user, conversation: Conversation) -> None:
if not require_capability(user, "conversations.call", conversation):
raise CallAccessDenied("Нет доступа к звонкам")
- from hub_platform.conversations.selectors import conversation_is_visible
+ from chatballs.conversations.selectors import conversation_is_visible
if not conversation_is_visible(actor=user, conversation=conversation):
raise CallAccessDenied("Диалог вне групп сотрудника")
diff --git a/apps/backend/hub_platform/calls/public_access.py b/apps/backend/chatballs/calls/public_access.py
similarity index 93%
rename from apps/backend/hub_platform/calls/public_access.py
rename to apps/backend/chatballs/calls/public_access.py
index 4fb22df..f1a2864 100644
--- a/apps/backend/hub_platform/calls/public_access.py
+++ b/apps/backend/chatballs/calls/public_access.py
@@ -4,14 +4,14 @@ from dataclasses import dataclass
from django.utils import timezone
-from hub_platform.calls.errors import (
+from chatballs.calls.errors import (
CallAccessDenied,
CallConflict,
CallInvalidTransition,
CallTokenError,
)
-from hub_platform.calls.lifecycle import finish_call, transition_call
-from hub_platform.calls.models import (
+from chatballs.calls.lifecycle import finish_call, transition_call
+from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
CallEndedBy,
CallInvite,
@@ -19,17 +19,17 @@ from hub_platform.calls.models import (
CallStatus,
ParticipantSide,
)
-from hub_platform.calls.permissions import ensure_call_access
-from hub_platform.calls.tokens import (
+from chatballs.calls.permissions import ensure_call_access
+from chatballs.calls.tokens import (
CallAccessClaims,
hash_invite_token,
issue_call_access_token,
verify_call_access_token,
)
-from hub_platform.identity.models import Organization, OrganizationMembership
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.ingress import call_invite_route, call_session_route
+from chatballs.identity.models import Organization, OrganizationMembership
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.ingress import call_invite_route, call_session_route
@dataclass(frozen=True)
diff --git a/apps/backend/hub_platform/calls/public_urls.py b/apps/backend/chatballs/calls/public_urls.py
similarity index 93%
rename from apps/backend/hub_platform/calls/public_urls.py
rename to apps/backend/chatballs/calls/public_urls.py
index 9eb1605..8d7ea54 100644
--- a/apps/backend/hub_platform/calls/public_urls.py
+++ b/apps/backend/chatballs/calls/public_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.calls import views
+from chatballs.calls import views
urlpatterns = [
path("invites/resolve/", views.InviteResolveView.as_view(), name="call-invite-resolve"),
diff --git a/apps/backend/hub_platform/calls/routing.py b/apps/backend/chatballs/calls/routing.py
similarity index 64%
rename from apps/backend/hub_platform/calls/routing.py
rename to apps/backend/chatballs/calls/routing.py
index 4d90829..b560057 100644
--- a/apps/backend/hub_platform/calls/routing.py
+++ b/apps/backend/chatballs/calls/routing.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.calls.consumers import CallSignalingConsumer
+from chatballs.calls.consumers import CallSignalingConsumer
websocket_urlpatterns = [
path("ws/calls/", CallSignalingConsumer.as_asgi()),
diff --git a/apps/backend/hub_platform/calls/serializers.py b/apps/backend/chatballs/calls/serializers.py
similarity index 89%
rename from apps/backend/hub_platform/calls/serializers.py
rename to apps/backend/chatballs/calls/serializers.py
index a6923af..b7a717b 100644
--- a/apps/backend/hub_platform/calls/serializers.py
+++ b/apps/backend/chatballs/calls/serializers.py
@@ -1,7 +1,7 @@
from django.conf import settings
-from hub_platform.calls.models import CallSession
-from hub_platform.calls.turn import turn_credentials
+from chatballs.calls.models import CallSession
+from chatballs.calls.turn import turn_credentials
def _iso(value):
@@ -66,13 +66,13 @@ def ice_servers_payload() -> list[dict]:
# fallback с краткоживущими credentials. Генерируется на каждый запрос токена,
# поэтому клиент всегда получает не истёкшие TURN credentials.
servers: list[dict] = []
- if settings.CUS_CALL_STUN_URLS:
- servers.append({"urls": list(settings.CUS_CALL_STUN_URLS)})
- if settings.CUS_CALL_TURN_URLS and settings.CUS_CALL_TURN_SECRET:
+ if settings.CHATBALLS_CALL_STUN_URLS:
+ servers.append({"urls": list(settings.CHATBALLS_CALL_STUN_URLS)})
+ if settings.CHATBALLS_CALL_TURN_URLS and settings.CHATBALLS_CALL_TURN_SECRET:
username, credential = turn_credentials()
servers.append(
{
- "urls": list(settings.CUS_CALL_TURN_URLS),
+ "urls": list(settings.CHATBALLS_CALL_TURN_URLS),
"username": username,
"credential": credential,
}
diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/chatballs/calls/services.py
similarity index 93%
rename from apps/backend/hub_platform/calls/services.py
rename to apps/backend/chatballs/calls/services.py
index fdf02ad..575288b 100644
--- a/apps/backend/hub_platform/calls/services.py
+++ b/apps/backend/chatballs/calls/services.py
@@ -7,15 +7,15 @@ from django.conf import settings
from django.db import IntegrityError, transaction
from django.utils import timezone
-from hub_platform.calls.errors import (
+from chatballs.calls.errors import (
CallAccessDenied,
CallConflict,
CallInvalidTransition,
CallTokenError,
)
-from hub_platform.calls.lifecycle import transition_call
-from hub_platform.calls.metrics import record_call_metric
-from hub_platform.calls.models import (
+from chatballs.calls.lifecycle import transition_call
+from chatballs.calls.metrics import record_call_metric
+from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
UNFINISHED_CALL_STATUSES,
CallEndedBy,
@@ -27,8 +27,8 @@ from hub_platform.calls.models import (
InviteDeliveryStatus,
ParticipantSide,
)
-from hub_platform.calls.permissions import ensure_call_access, ensure_conversation_call_access
-from hub_platform.calls.public_access import (
+from chatballs.calls.permissions import ensure_call_access, ensure_conversation_call_access
+from chatballs.calls.public_access import (
ResolvedInvite,
accept_call_by_access_token,
authorize_call_access_context,
@@ -38,11 +38,11 @@ from hub_platform.calls.public_access import (
end_call_by_access_token,
resolve_invite,
)
-from hub_platform.calls.tokens import (
+from chatballs.calls.tokens import (
issue_call_access_token,
issue_invite_token,
)
-from hub_platform.conversations.models import (
+from chatballs.conversations.models import (
ConnectionIdentity,
ControlMode,
Conversation,
@@ -50,10 +50,10 @@ from hub_platform.conversations.models import (
Message,
MessageAuthor,
)
-from hub_platform.conversations.services import ClaimError, claim_locked_conversation
-from hub_platform.events.services import DomainEvent, enqueue_event
-from hub_platform.integrations.models import IntegrationProvider
-from hub_platform.tenancy.context import TenantContext
+from chatballs.conversations.services import ClaimError, claim_locked_conversation
+from chatballs.events.services import DomainEvent, enqueue_event
+from chatballs.integrations.models import IntegrationProvider
+from chatballs.tenancy.context import TenantContext
__all__ = (
"accept_call_by_access_token",
@@ -153,7 +153,7 @@ def create_call_request(
call_session=call,
connection_identity=identity,
token_hash=token_hash,
- expires_at=timezone.now() + timedelta(seconds=settings.CUS_CALL_INVITE_TTL_SECONDS),
+ expires_at=timezone.now() + timedelta(seconds=settings.CHATBALLS_CALL_INVITE_TTL_SECONDS),
)
CallParticipant.objects.bulk_create(
[
diff --git a/apps/backend/hub_platform/calls/signaling.py b/apps/backend/chatballs/calls/signaling.py
similarity index 94%
rename from apps/backend/hub_platform/calls/signaling.py
rename to apps/backend/chatballs/calls/signaling.py
index 218ab67..db49f9c 100644
--- a/apps/backend/hub_platform/calls/signaling.py
+++ b/apps/backend/chatballs/calls/signaling.py
@@ -7,18 +7,18 @@ PostgreSQL; Redis (channel layer) — только fan-out и presence.
from django.utils import timezone
-from hub_platform.calls.errors import CallInvalidTransition
-from hub_platform.calls.lifecycle import finish_call, transition_call
-from hub_platform.calls.models import (
+from chatballs.calls.errors import CallInvalidTransition
+from chatballs.calls.lifecycle import finish_call, transition_call
+from chatballs.calls.models import (
CallParticipant,
CallSession,
CallStatus,
ParticipantConnectionState,
TERMINAL_CALL_STATUSES,
)
-from hub_platform.calls.serializers import public_call_state_payload
-from hub_platform.calls.services import record_call_metric
-from hub_platform.tenancy.context import TenantContext
+from chatballs.calls.serializers import public_call_state_payload
+from chatballs.calls.services import record_call_metric
+from chatballs.tenancy.context import TenantContext
def _call(context: TenantContext, call_id) -> CallSession:
return CallSession.objects.select_related("initiated_by").get(
diff --git a/apps/backend/hub_platform/calls/__init__.py b/apps/backend/chatballs/calls/tests/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/calls/__init__.py
rename to apps/backend/chatballs/calls/tests/__init__.py
diff --git a/apps/backend/hub_platform/calls/tests/helpers.py b/apps/backend/chatballs/calls/tests/helpers.py
similarity index 86%
rename from apps/backend/hub_platform/calls/tests/helpers.py
rename to apps/backend/chatballs/calls/tests/helpers.py
index 4143370..eda5545 100644
--- a/apps/backend/hub_platform/calls/tests/helpers.py
+++ b/apps/backend/chatballs/calls/tests/helpers.py
@@ -1,16 +1,16 @@
from django.test import TestCase
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.models import ConnectionIdentity, Contact, Conversation
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import (
+from chatballs.channels.models import Channel
+from chatballs.conversations.models import ConnectionIdentity, Contact, Conversation
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
-from hub_platform.tenancy.context import TenantActorKind, TenantContext
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
+from chatballs.tenancy.context import TenantActorKind, TenantContext
def create_call_request(*, conversation_id: int, initiator: HumanUser):
@@ -21,7 +21,7 @@ def create_call_request(*, conversation_id: int, initiator: HumanUser):
organization_id=conversation.organization_id,
user=initiator,
)
- from hub_platform.calls.services import create_call_request as create_with_context
+ from chatballs.calls.services import create_call_request as create_with_context
return create_with_context(
context=TenantContext.for_membership(membership),
@@ -32,7 +32,7 @@ def create_call_request(*, conversation_id: int, initiator: HumanUser):
def expire_stale_calls(organization: Organization) -> int:
"""Run one organization's maintenance pass in legacy call fixtures."""
- from hub_platform.calls.maintenance import expire_stale_calls as expire_with_context
+ from chatballs.calls.maintenance import expire_stale_calls as expire_with_context
return expire_with_context(
TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/chatballs/calls/tests/test_api.py
similarity index 93%
rename from apps/backend/hub_platform/calls/tests/test_api.py
rename to apps/backend/chatballs/calls/tests/test_api.py
index b461c7c..ea01401 100644
--- a/apps/backend/hub_platform/calls/tests/test_api.py
+++ b/apps/backend/chatballs/calls/tests/test_api.py
@@ -1,11 +1,11 @@
import json
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.calls.models import CallKind, CallSession, CallStatus, ParticipantSide
-from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
-from hub_platform.calls.tokens import verify_call_access_token
-from hub_platform.conversations.models import ControlMode
+from chatballs.calls.models import CallKind, CallSession, CallStatus, ParticipantSide
+from chatballs.calls.tests.helpers import CallTestCase, create_call_request
+from chatballs.calls.tokens import verify_call_access_token
+from chatballs.conversations.models import ControlMode
class InternalCallApiTests(CallTestCase):
diff --git a/apps/backend/hub_platform/calls/tests/test_flow.py b/apps/backend/chatballs/calls/tests/test_flow.py
similarity index 92%
rename from apps/backend/hub_platform/calls/tests/test_flow.py
rename to apps/backend/chatballs/calls/tests/test_flow.py
index 824adae..95687fd 100644
--- a/apps/backend/hub_platform/calls/tests/test_flow.py
+++ b/apps/backend/chatballs/calls/tests/test_flow.py
@@ -7,25 +7,25 @@ from urllib.parse import parse_qs, urlparse
from unittest import mock
from django.utils import timezone
-from hub_platform.testing import TenantAPIClient as APIClient, tenant_context_for
+from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
-from hub_platform.calls.event_handlers import handle_call_invite_send
-from hub_platform.calls.models import (
+from chatballs.calls.event_handlers import handle_call_invite_send
+from chatballs.calls.models import (
CallEndedBy,
CallInvite,
CallSession,
CallStatus,
InviteDeliveryStatus,
)
-from hub_platform.calls.services import (
+from chatballs.calls.services import (
open_call_for_identity,
decline_call_for_identity,
)
-from hub_platform.calls.tests.helpers import CallTestCase, create_call_request, expire_stale_calls
-from hub_platform.calls.tokens import hash_invite_token
-from hub_platform.conversations.models import Conversation, Message
-from hub_platform.events.models import OutboxEvent
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
+from chatballs.calls.tests.helpers import CallTestCase, create_call_request, expire_stale_calls
+from chatballs.calls.tokens import hash_invite_token
+from chatballs.conversations.models import Conversation, Message
+from chatballs.events.models import OutboxEvent
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
class CancelCallApiTests(CallTestCase):
@@ -152,7 +152,7 @@ class CustomerAccessApiTests(CallTestCase):
def test_state_visible_after_cancellation(self) -> None:
token = self._customer_token()
call = CallSession.objects.get()
- from hub_platform.calls.services import cancel_call
+ from chatballs.calls.services import cancel_call
cancel_call(
context=tenant_context_for(self.owner, self.organization),
@@ -168,7 +168,7 @@ class CustomerAccessApiTests(CallTestCase):
def test_accept_after_cancel_is_rejected(self) -> None:
token = self._customer_token()
- from hub_platform.calls.services import cancel_call
+ from chatballs.calls.services import cancel_call
cancel_call(
context=tenant_context_for(self.owner, self.organization),
@@ -198,9 +198,9 @@ class WebchatCallFlowTests(CallTestCase):
self.assertEqual(declined.status, CallStatus.DECLINED)
def test_poll_payload_contains_invite(self) -> None:
- from hub_platform.webchat.models import WebSession
- from hub_platform.webchat.services import messages_payload
- from hub_platform.webchat.testing import create_web_widget
+ from chatballs.webchat.models import WebSession
+ from chatballs.webchat.services import messages_payload
+ from chatballs.webchat.testing import create_web_widget
widget = create_web_widget(
self.channel,
@@ -253,7 +253,7 @@ class MessengerDeliveryTests(CallTestCase):
return True
with mock.patch(
- "hub_platform.calls.event_handlers.transports.send_call_invite", side_effect=fake_send
+ "chatballs.calls.event_handlers.transports.send_call_invite", side_effect=fake_send
):
handle_call_invite_send(
{"callSessionId": str(created.call_session.id)},
@@ -277,7 +277,7 @@ class MessengerDeliveryTests(CallTestCase):
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
old_hash = created.call_session.invite.token_hash
with mock.patch(
- "hub_platform.calls.event_handlers.transports.send_call_invite", return_value=False
+ "chatballs.calls.event_handlers.transports.send_call_invite", return_value=False
):
with self.assertRaises(Exception):
handle_call_invite_send(
@@ -292,7 +292,7 @@ class MessengerDeliveryTests(CallTestCase):
def test_repeated_delivery_is_idempotent(self) -> None:
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
with mock.patch(
- "hub_platform.calls.event_handlers.transports.send_call_invite", return_value=True
+ "chatballs.calls.event_handlers.transports.send_call_invite", return_value=True
) as sender:
context = tenant_context_for(self.owner, self.organization)
handle_call_invite_send({"callSessionId": str(created.call_session.id)}, context)
@@ -339,7 +339,7 @@ class ExpirySweepTests(CallTestCase):
def test_accepted_call_without_connection_fails_after_grace(self) -> None:
create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
token = open_call_for_identity(identity=self.identity).customer_access_token
- from hub_platform.calls.services import accept_call_by_access_token
+ from chatballs.calls.services import accept_call_by_access_token
accept_call_by_access_token(token=token)
CallSession.objects.update(accepted_at=timezone.now() - timedelta(hours=1))
diff --git a/apps/backend/hub_platform/calls/tests/test_metrics.py b/apps/backend/chatballs/calls/tests/test_metrics.py
similarity index 92%
rename from apps/backend/hub_platform/calls/tests/test_metrics.py
rename to apps/backend/chatballs/calls/tests/test_metrics.py
index 9c4b9d5..b7e826b 100644
--- a/apps/backend/hub_platform/calls/tests/test_metrics.py
+++ b/apps/backend/chatballs/calls/tests/test_metrics.py
@@ -1,11 +1,11 @@
import uuid
-from hub_platform.calls import signaling
-from hub_platform.calls.models import CallConnectionType, CallMetric, ParticipantSide
-from hub_platform.calls.serializers import call_payload
-from hub_platform.calls.services import record_call_metric
-from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
-from hub_platform.testing import system_tenant_context
+from chatballs.calls import signaling
+from chatballs.calls.models import CallConnectionType, CallMetric, ParticipantSide
+from chatballs.calls.serializers import call_payload
+from chatballs.calls.services import record_call_metric
+from chatballs.calls.tests.helpers import CallTestCase, create_call_request
+from chatballs.testing import system_tenant_context
class RecordCallMetricTests(CallTestCase):
diff --git a/apps/backend/hub_platform/calls/tests/test_services.py b/apps/backend/chatballs/calls/tests/test_services.py
similarity index 94%
rename from apps/backend/hub_platform/calls/tests/test_services.py
rename to apps/backend/chatballs/calls/tests/test_services.py
index 805dd6c..9bd277a 100644
--- a/apps/backend/hub_platform/calls/tests/test_services.py
+++ b/apps/backend/chatballs/calls/tests/test_services.py
@@ -1,17 +1,17 @@
from django.db import IntegrityError, transaction
-from hub_platform.calls.errors import CallAccessDenied, CallConflict, CallInvalidTransition
-from hub_platform.calls.lifecycle import transition_call
-from hub_platform.calls.models import (
+from chatballs.calls.errors import CallAccessDenied, CallConflict, CallInvalidTransition
+from chatballs.calls.lifecycle import transition_call
+from chatballs.calls.models import (
CallEndedBy,
CallParticipant,
CallSession,
CallStatus,
ParticipantSide,
)
-from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
-from hub_platform.calls.tokens import hash_invite_token
-from hub_platform.conversations.models import ControlMode, LifecycleState, Message
+from chatballs.calls.tests.helpers import CallTestCase, create_call_request
+from chatballs.calls.tokens import hash_invite_token
+from chatballs.conversations.models import ControlMode, LifecycleState, Message
class CallCreationTests(CallTestCase):
diff --git a/apps/backend/hub_platform/calls/tests/test_signaling.py b/apps/backend/chatballs/calls/tests/test_signaling.py
similarity index 97%
rename from apps/backend/hub_platform/calls/tests/test_signaling.py
rename to apps/backend/chatballs/calls/tests/test_signaling.py
index 24a40e1..620283d 100644
--- a/apps/backend/hub_platform/calls/tests/test_signaling.py
+++ b/apps/backend/chatballs/calls/tests/test_signaling.py
@@ -9,20 +9,20 @@ from channels.testing import WebsocketCommunicator
from django.test import TransactionTestCase
from django.utils import timezone
-from hub_backend.asgi import application
-from hub_platform.calls.models import (
+from chatballs_backend.asgi import application
+from chatballs.calls.models import (
CallParticipant,
CallSession,
CallStatus,
ParticipantConnectionState,
ParticipantSide,
)
-from hub_platform.calls.services import (
+from chatballs.calls.services import (
accept_call_by_access_token,
open_call_for_identity,
)
-from hub_platform.calls.tests.helpers import CallDomainMixin, create_call_request, expire_stale_calls
-from hub_platform.conversations.models import Message
+from chatballs.calls.tests.helpers import CallDomainMixin, create_call_request, expire_stale_calls
+from chatballs.conversations.models import Message
WS_PATH = "/ws/calls/"
diff --git a/apps/backend/hub_platform/calls/tests/test_tokens.py b/apps/backend/chatballs/calls/tests/test_tokens.py
similarity index 91%
rename from apps/backend/hub_platform/calls/tests/test_tokens.py
rename to apps/backend/chatballs/calls/tests/test_tokens.py
index 6e8318d..dd57626 100644
--- a/apps/backend/hub_platform/calls/tests/test_tokens.py
+++ b/apps/backend/chatballs/calls/tests/test_tokens.py
@@ -4,15 +4,15 @@ import uuid
from django.test import override_settings
from django.utils import timezone
-from hub_platform.calls.errors import CallTokenError
-from hub_platform.calls.lifecycle import transition_call
-from hub_platform.calls.models import CallStatus, ParticipantSide
-from hub_platform.calls.services import (
+from chatballs.calls.errors import CallTokenError
+from chatballs.calls.lifecycle import transition_call
+from chatballs.calls.models import CallStatus, ParticipantSide
+from chatballs.calls.services import (
authorize_call_access_token,
resolve_invite,
)
-from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
-from hub_platform.calls.tokens import (
+from chatballs.calls.tests.helpers import CallTestCase, create_call_request
+from chatballs.calls.tokens import (
issue_call_access_token,
verify_call_access_token,
)
@@ -78,7 +78,7 @@ class AccessTokenTests(CallTestCase):
with self.assertRaises(CallTokenError):
verify_call_access_token("%%%.$$$")
- @override_settings(CUS_CALL_ACCESS_TTL_SECONDS=-1)
+ @override_settings(CHATBALLS_CALL_ACCESS_TTL_SECONDS=-1)
def test_expired_access_token_is_rejected(self) -> None:
token = issue_call_access_token(
call_session_id=uuid.uuid4(),
diff --git a/apps/backend/hub_platform/calls/tests/test_turn.py b/apps/backend/chatballs/calls/tests/test_turn.py
similarity index 75%
rename from apps/backend/hub_platform/calls/tests/test_turn.py
rename to apps/backend/chatballs/calls/tests/test_turn.py
index 19d3859..ffd9f8e 100644
--- a/apps/backend/hub_platform/calls/tests/test_turn.py
+++ b/apps/backend/chatballs/calls/tests/test_turn.py
@@ -5,8 +5,8 @@ import time
from django.test import SimpleTestCase, override_settings
-from hub_platform.calls.serializers import ice_servers_payload
-from hub_platform.calls.turn import turn_credentials
+from chatballs.calls.serializers import ice_servers_payload
+from chatballs.calls.turn import turn_credentials
SECRET = "coturn-shared-secret"
TURN_URLS = ["turn:hub.edevs.tech:3478?transport=udp", "turn:hub.edevs.tech:3478?transport=tcp"]
@@ -18,7 +18,7 @@ def _expected_credential(username: str, secret: str = SECRET) -> str:
return base64.b64encode(digest).decode("ascii")
-@override_settings(CUS_CALL_TURN_SECRET=SECRET, CUS_CALL_TURN_TTL_SECONDS=3600)
+@override_settings(CHATBALLS_CALL_TURN_SECRET=SECRET, CHATBALLS_CALL_TURN_TTL_SECONDS=3600)
class TurnCredentialsTests(SimpleTestCase):
def test_username_encodes_expiry_and_label(self) -> None:
before = int(time.time())
@@ -36,26 +36,26 @@ class TurnCredentialsTests(SimpleTestCase):
def test_credential_rotates_with_secret(self) -> None:
_u, credential = turn_credentials(now=1_700_000_000)
- with override_settings(CUS_CALL_TURN_SECRET="other-secret"):
+ with override_settings(CHATBALLS_CALL_TURN_SECRET="other-secret"):
_u2, other = turn_credentials(now=1_700_000_000)
self.assertNotEqual(credential, other)
class IceServersPayloadTests(SimpleTestCase):
- @override_settings(CUS_CALL_STUN_URLS=[], CUS_CALL_TURN_URLS=[], CUS_CALL_TURN_SECRET="")
+ @override_settings(CHATBALLS_CALL_STUN_URLS=[], CHATBALLS_CALL_TURN_URLS=[], CHATBALLS_CALL_TURN_SECRET="")
def test_empty_without_configuration(self) -> None:
self.assertEqual(ice_servers_payload(), [])
- @override_settings(CUS_CALL_STUN_URLS=STUN_URLS, CUS_CALL_TURN_URLS=[], CUS_CALL_TURN_SECRET="")
+ @override_settings(CHATBALLS_CALL_STUN_URLS=STUN_URLS, CHATBALLS_CALL_TURN_URLS=[], CHATBALLS_CALL_TURN_SECRET="")
def test_stun_only(self) -> None:
servers = ice_servers_payload()
self.assertEqual(servers, [{"urls": STUN_URLS}])
@override_settings(
- CUS_CALL_STUN_URLS=STUN_URLS,
- CUS_CALL_TURN_URLS=TURN_URLS,
- CUS_CALL_TURN_SECRET=SECRET,
- CUS_CALL_TURN_TTL_SECONDS=3600,
+ CHATBALLS_CALL_STUN_URLS=STUN_URLS,
+ CHATBALLS_CALL_TURN_URLS=TURN_URLS,
+ CHATBALLS_CALL_TURN_SECRET=SECRET,
+ CHATBALLS_CALL_TURN_TTL_SECONDS=3600,
)
def test_direct_first_then_turn_fallback(self) -> None:
servers = ice_servers_payload()
@@ -67,7 +67,7 @@ class IceServersPayloadTests(SimpleTestCase):
self.assertIn(":hub", turn["username"])
self.assertEqual(turn["credential"], _expected_credential(turn["username"]))
- @override_settings(CUS_CALL_STUN_URLS=[], CUS_CALL_TURN_URLS=TURN_URLS, CUS_CALL_TURN_SECRET="")
+ @override_settings(CHATBALLS_CALL_STUN_URLS=[], CHATBALLS_CALL_TURN_URLS=TURN_URLS, CHATBALLS_CALL_TURN_SECRET="")
def test_turn_urls_without_secret_are_not_exposed(self) -> None:
# Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе.
self.assertEqual(ice_servers_payload(), [])
diff --git a/apps/backend/hub_platform/calls/tokens.py b/apps/backend/chatballs/calls/tokens.py
similarity index 95%
rename from apps/backend/hub_platform/calls/tokens.py
rename to apps/backend/chatballs/calls/tokens.py
index b78ecdf..7ff9451 100644
--- a/apps/backend/hub_platform/calls/tokens.py
+++ b/apps/backend/chatballs/calls/tokens.py
@@ -12,8 +12,8 @@ from dataclasses import dataclass
from django.conf import settings
-from hub_platform.calls.errors import CallTokenError
-from hub_platform.calls.models import ParticipantSide
+from chatballs.calls.errors import CallTokenError
+from chatballs.calls.models import ParticipantSide
ACCESS_TOKEN_VERSION = 1
ACCESS_TOKEN_PURPOSE = "call-access"
@@ -74,7 +74,7 @@ def issue_call_access_token(*, call_session_id: uuid.UUID, side: str, subject_id
"side": side,
"subject_id": str(subject_id),
"iat": now,
- "exp": now + settings.CUS_CALL_ACCESS_TTL_SECONDS,
+ "exp": now + settings.CHATBALLS_CALL_ACCESS_TTL_SECONDS,
"jti": secrets.token_urlsafe(16),
}
encoded = _b64encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
diff --git a/apps/backend/hub_platform/calls/turn.py b/apps/backend/chatballs/calls/turn.py
similarity index 81%
rename from apps/backend/hub_platform/calls/turn.py
rename to apps/backend/chatballs/calls/turn.py
index f91ed3c..46376bb 100644
--- a/apps/backend/hub_platform/calls/turn.py
+++ b/apps/backend/chatballs/calls/turn.py
@@ -16,14 +16,14 @@ def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str
credential = base64(HMAC-SHA1(static-auth-secret, username))
Coturn принимает пару, пока не истёк expiry в username и подпись совпадает с
- его `static-auth-secret`. Секрет (`CUS_CALL_TURN_SECRET`) общий с сервисом
+ его `static-auth-secret`. Секрет (`CHATBALLS_CALL_TURN_SECRET`) общий с сервисом
coturn и наружу не отдаётся — клиент получает только производные credentials.
"""
moment = int(time.time()) if now is None else int(now)
- expiry = moment + settings.CUS_CALL_TURN_TTL_SECONDS
+ expiry = moment + settings.CHATBALLS_CALL_TURN_TTL_SECONDS
username = f"{expiry}:{label}"
digest = hmac.new(
- settings.CUS_CALL_TURN_SECRET.encode("utf-8"),
+ settings.CHATBALLS_CALL_TURN_SECRET.encode("utf-8"),
username.encode("utf-8"),
hashlib.sha1,
).digest()
diff --git a/apps/backend/hub_platform/calls/urls.py b/apps/backend/chatballs/calls/urls.py
similarity index 95%
rename from apps/backend/hub_platform/calls/urls.py
rename to apps/backend/chatballs/calls/urls.py
index 1379227..460c81e 100644
--- a/apps/backend/hub_platform/calls/urls.py
+++ b/apps/backend/chatballs/calls/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.calls import views
+from chatballs.calls import views
urlpatterns = [
path(
diff --git a/apps/backend/hub_platform/calls/views.py b/apps/backend/chatballs/calls/views.py
similarity index 95%
rename from apps/backend/hub_platform/calls/views.py
rename to apps/backend/chatballs/calls/views.py
index c46c5ee..ac5cb56 100644
--- a/apps/backend/hub_platform/calls/views.py
+++ b/apps/backend/chatballs/calls/views.py
@@ -4,16 +4,16 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
-from hub_platform.calls.errors import CallAccessDenied, CallConflict, CallTokenError
-from hub_platform.calls.models import CallKind, CallSession
-from hub_platform.calls.permissions import ensure_call_access, ensure_conversation_call_access
-from hub_platform.calls.serializers import (
+from chatballs.calls.errors import CallAccessDenied, CallConflict, CallTokenError
+from chatballs.calls.models import CallKind, CallSession
+from chatballs.calls.permissions import ensure_call_access, ensure_conversation_call_access
+from chatballs.calls.serializers import (
call_payload,
ice_servers_payload,
public_call_state_payload,
public_invite_payload,
)
-from hub_platform.calls.services import (
+from chatballs.calls.services import (
accept_call_by_access_token,
active_call_for_conversation,
call_state_by_access_token,
@@ -24,8 +24,8 @@ from hub_platform.calls.services import (
issue_staff_access_token,
resolve_invite,
)
-from hub_platform.conversations.models import Conversation
-from hub_platform.identity.audit import record_audit_event
+from chatballs.conversations.models import Conversation
+from chatballs.identity.audit import record_audit_event
def _call_queryset():
diff --git a/apps/backend/hub_platform/channels/__init__.py b/apps/backend/chatballs/channels/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/channels/__init__.py
rename to apps/backend/chatballs/channels/__init__.py
diff --git a/apps/backend/hub_platform/channels/admin.py b/apps/backend/chatballs/channels/admin.py
similarity index 82%
rename from apps/backend/hub_platform/channels/admin.py
rename to apps/backend/chatballs/channels/admin.py
index 5579f57..06d30ee 100644
--- a/apps/backend/hub_platform/channels/admin.py
+++ b/apps/backend/chatballs/channels/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from hub_platform.channels.models import Channel
+from chatballs.channels.models import Channel
@admin.register(Channel)
diff --git a/apps/backend/chatballs/channels/apps.py b/apps/backend/chatballs/channels/apps.py
new file mode 100644
index 0000000..1cec473
--- /dev/null
+++ b/apps/backend/chatballs/channels/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class ChannelsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ label = "channels"
+ name = "chatballs.channels"
+ verbose_name = "Processing channels (AI context)"
diff --git a/apps/backend/hub_platform/channels/authorization.py b/apps/backend/chatballs/channels/authorization.py
similarity index 93%
rename from apps/backend/hub_platform/channels/authorization.py
rename to apps/backend/chatballs/channels/authorization.py
index d6ca2d6..3596305 100644
--- a/apps/backend/hub_platform/channels/authorization.py
+++ b/apps/backend/chatballs/channels/authorization.py
@@ -9,8 +9,8 @@ from __future__ import annotations
from django.core.exceptions import PermissionDenied
-from hub_platform.identity.policy import has_capability_any_scope
-from hub_platform.tenancy.context import TenantContext
+from chatballs.identity.policy import has_capability_any_scope
+from chatballs.tenancy.context import TenantContext
CHANNELS_VIEW = "channels.view"
CHANNELS_MANAGE = "channels.manage"
diff --git a/apps/backend/hub_platform/channels/migrations/0001_initial.py b/apps/backend/chatballs/channels/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0001_initial.py
rename to apps/backend/chatballs/channels/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/channels/migrations/0002_channel_system_prompt.py b/apps/backend/chatballs/channels/migrations/0002_channel_system_prompt.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0002_channel_system_prompt.py
rename to apps/backend/chatballs/channels/migrations/0002_channel_system_prompt.py
diff --git a/apps/backend/hub_platform/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py b/apps/backend/chatballs/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py
rename to apps/backend/chatballs/channels/migrations/0003_channel_allow_anonymous_sessions_and_more.py
diff --git a/apps/backend/hub_platform/channels/migrations/0004_remove_channel_ai_fields.py b/apps/backend/chatballs/channels/migrations/0004_remove_channel_ai_fields.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0004_remove_channel_ai_fields.py
rename to apps/backend/chatballs/channels/migrations/0004_remove_channel_ai_fields.py
diff --git a/apps/backend/hub_platform/channels/migrations/0005_enforce_policy_invariants.py b/apps/backend/chatballs/channels/migrations/0005_enforce_policy_invariants.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0005_enforce_policy_invariants.py
rename to apps/backend/chatballs/channels/migrations/0005_enforce_policy_invariants.py
diff --git a/apps/backend/hub_platform/channels/migrations/0006_remove_channel_department.py b/apps/backend/chatballs/channels/migrations/0006_remove_channel_department.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0006_remove_channel_department.py
rename to apps/backend/chatballs/channels/migrations/0006_remove_channel_department.py
diff --git a/apps/backend/hub_platform/channels/migrations/0007_channel_group.py b/apps/backend/chatballs/channels/migrations/0007_channel_group.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/0007_channel_group.py
rename to apps/backend/chatballs/channels/migrations/0007_channel_group.py
diff --git a/apps/backend/hub_platform/channels/migrations/__init__.py b/apps/backend/chatballs/channels/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/channels/migrations/__init__.py
rename to apps/backend/chatballs/channels/migrations/__init__.py
diff --git a/apps/backend/hub_platform/channels/models.py b/apps/backend/chatballs/channels/models.py
similarity index 100%
rename from apps/backend/hub_platform/channels/models.py
rename to apps/backend/chatballs/channels/models.py
diff --git a/apps/backend/chatballs/channels/policy.py b/apps/backend/chatballs/channels/policy.py
new file mode 100644
index 0000000..adcdebb
--- /dev/null
+++ b/apps/backend/chatballs/channels/policy.py
@@ -0,0 +1,166 @@
+"""Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7).
+
+Источник истины — пять булевых полей `Channel`. Пресет существует только в API
+и UI: он заполняет флаги при создании и полем модели не является.
+
+Инварианты выражают одно правило `ADR-HUB-0019`: непродуктовый канал не
+формирует коммерческих действий. Проверяются по итоговому состоянию, а не по
+переданным полям, — частичное применение запрещено.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, fields, replace
+
+from django.core.exceptions import ValidationError
+
+from chatballs.channels.models import Channel
+
+POLICY_FIELDS = (
+ "requires_authenticated_product_identity",
+ "allow_anonymous_sessions",
+ "allow_self_reported_contact",
+ "allow_sales_attribution",
+ "allow_checkout_actions",
+)
+
+# Ключ payload -> имя поля модели (SPEC §6.1).
+POLICY_API_FIELDS = {
+ "requiresAuthenticatedProductIdentity": "requires_authenticated_product_identity",
+ "allowAnonymousSessions": "allow_anonymous_sessions",
+ "allowSelfReportedContact": "allow_self_reported_contact",
+ "allowSalesAttribution": "allow_sales_attribution",
+ "allowCheckoutActions": "allow_checkout_actions",
+}
+
+
+class PolicyPreset:
+ SALES = "SALES"
+ SUPPORT = "SUPPORT"
+ CUSTOM = "CUSTOM"
+
+
+@dataclass(frozen=True, slots=True)
+class ChannelPolicy:
+ requires_authenticated_product_identity: bool
+ allow_anonymous_sessions: bool
+ allow_self_reported_contact: bool
+ allow_sales_attribution: bool
+ allow_checkout_actions: bool
+
+ @classmethod
+ def from_channel(cls, channel: Channel) -> ChannelPolicy:
+ return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS})
+
+ @classmethod
+ def from_preset(cls, preset: str) -> ChannelPolicy:
+ return _PRESETS[preset]
+
+ def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy:
+ return replace(self, **changes)
+
+ def as_model_fields(self) -> dict[str, bool]:
+ return {field.name: getattr(self, field.name) for field in fields(self)}
+
+ def as_payload(self) -> dict[str, bool]:
+ return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()}
+
+
+# SPEC §3.3. SALES и SUPPORT требуют продукта — иначе нарушают P1-P3.
+_PRESETS = {
+ PolicyPreset.SALES: ChannelPolicy(
+ requires_authenticated_product_identity=False,
+ allow_anonymous_sessions=True,
+ allow_self_reported_contact=True,
+ allow_sales_attribution=True,
+ allow_checkout_actions=True,
+ ),
+ # Комбинация support-канала из SPEC-HUB-0010 §4.2.
+ PolicyPreset.SUPPORT: ChannelPolicy(
+ requires_authenticated_product_identity=True,
+ allow_anonymous_sessions=False,
+ allow_self_reported_contact=False,
+ allow_sales_attribution=False,
+ allow_checkout_actions=False,
+ ),
+}
+
+
+@dataclass(frozen=True, slots=True)
+class PolicyViolation:
+ rule: str
+ field: str
+ detail: str
+
+ def payload(self) -> dict[str, str]:
+ return {"rule": self.rule, "field": self.field, "detail": self.detail}
+
+
+def policy_violations(
+ *, policy: ChannelPolicy, has_product: bool
+) -> tuple[PolicyViolation, ...]:
+ """Нарушения P1-P5 для итогового состояния канала (SPEC §3.2)."""
+ violations: list[PolicyViolation] = []
+ if not has_product:
+ if policy.allow_checkout_actions:
+ violations.append(
+ PolicyViolation(
+ "P1",
+ "allowCheckoutActions",
+ "Коммерческие действия недоступны непродуктовому каналу",
+ )
+ )
+ if policy.allow_sales_attribution:
+ violations.append(
+ PolicyViolation(
+ "P2",
+ "allowSalesAttribution",
+ "Attribution недоступна непродуктовому каналу",
+ )
+ )
+ if policy.requires_authenticated_product_identity:
+ violations.append(
+ PolicyViolation(
+ "P3",
+ "requiresAuthenticatedProductIdentity",
+ "Продуктовая идентичность требует продукта",
+ )
+ )
+ if policy.requires_authenticated_product_identity:
+ if policy.allow_anonymous_sessions:
+ violations.append(
+ PolicyViolation(
+ "P4",
+ "allowAnonymousSessions",
+ "Анонимные сессии несовместимы с обязательной идентичностью",
+ )
+ )
+ if policy.allow_self_reported_contact:
+ violations.append(
+ PolicyViolation(
+ "P5",
+ "allowSelfReportedContact",
+ "Самозаявленный контакт несовместим с обязательной идентичностью",
+ )
+ )
+ return tuple(violations)
+
+
+def require_valid_policy(*, policy: ChannelPolicy, has_product: bool) -> None:
+ violations = policy_violations(policy=policy, has_product=has_product)
+ if violations:
+ raise PolicyInvariantError(violations)
+
+
+class PolicyInvariantError(ValidationError):
+ """Отклонение целиком: канал не сохраняется ни в каком виде."""
+
+ def __init__(self, violations: tuple[PolicyViolation, ...]) -> None:
+ self.violations = violations
+ super().__init__("; ".join(violation.detail for violation in violations))
+
+ def payload(self) -> dict[str, object]:
+ return {
+ "detail": "; ".join(violation.detail for violation in self.violations),
+ "violations": [violation.payload() for violation in self.violations],
+ }
diff --git a/apps/backend/hub_platform/channels/runtime.py b/apps/backend/chatballs/channels/runtime.py
similarity index 86%
rename from apps/backend/hub_platform/channels/runtime.py
rename to apps/backend/chatballs/channels/runtime.py
index 2c38457..bf4fd17 100644
--- a/apps/backend/hub_platform/channels/runtime.py
+++ b/apps/backend/chatballs/channels/runtime.py
@@ -1,5 +1,5 @@
-from hub_platform.ai.provider.base import ChatResult, ProviderError
-from hub_platform.ai.runtime import run_agent_turn
+from chatballs.ai.provider.base import ChatResult, ProviderError
+from chatballs.ai.runtime import run_agent_turn
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
diff --git a/apps/backend/hub_platform/channels/selectors.py b/apps/backend/chatballs/channels/selectors.py
similarity index 87%
rename from apps/backend/hub_platform/channels/selectors.py
rename to apps/backend/chatballs/channels/selectors.py
index d6a7829..7232078 100644
--- a/apps/backend/hub_platform/channels/selectors.py
+++ b/apps/backend/chatballs/channels/selectors.py
@@ -1,10 +1,10 @@
from django.db.models import Count, Prefetch, Q, QuerySet
-from hub_platform.channels.authorization import CHANNELS_VIEW, has_organization_capability
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.models import LifecycleState
-from hub_platform.integrations.models import Integration
-from hub_platform.tenancy.context import TenantContext
+from chatballs.channels.authorization import CHANNELS_VIEW, has_organization_capability
+from chatballs.channels.models import Channel
+from chatballs.conversations.models import LifecycleState
+from chatballs.integrations.models import Integration
+from chatballs.tenancy.context import TenantContext
def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]:
diff --git a/apps/backend/hub_platform/channels/services.py b/apps/backend/chatballs/channels/services.py
similarity index 95%
rename from apps/backend/hub_platform/channels/services.py
rename to apps/backend/chatballs/channels/services.py
index d246d89..7a735e5 100644
--- a/apps/backend/hub_platform/channels/services.py
+++ b/apps/backend/chatballs/channels/services.py
@@ -8,13 +8,13 @@ from typing import Any
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.channels import authorization
-from hub_platform.channels.models import Channel
-from hub_platform.channels.policy import ChannelPolicy, require_valid_policy
-from hub_platform.identity.group_models import EmployeeGroup
-from hub_platform.integrations.models import Integration, IntegrationKind
-from hub_platform.products.models import Product
-from hub_platform.tenancy.context import TenantContext
+from chatballs.channels import authorization
+from chatballs.channels.models import Channel
+from chatballs.channels.policy import ChannelPolicy, require_valid_policy
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.integrations.models import Integration, IntegrationKind
+from chatballs.products.models import Product
+from chatballs.tenancy.context import TenantContext
# SPEC §3.1: slug 1-64, входит в embed-URL web-виджета и после создания immutable.
CODE_MAX_LENGTH = 64
diff --git a/apps/backend/chatballs/channels/test_policy_migration.py b/apps/backend/chatballs/channels/test_policy_migration.py
new file mode 100644
index 0000000..e93fc3a
--- /dev/null
+++ b/apps/backend/chatballs/channels/test_policy_migration.py
@@ -0,0 +1,94 @@
+"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.
+
+Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
+только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
+данных, поэтому она проверяется напрямую на реальном реестре моделей — это
+честнее, чем поднимать историческое состояние, где `identity` откатывается
+рассинхронно со схемой БД.
+"""
+
+import importlib
+
+from django.apps import apps
+from django.test import TestCase
+
+from chatballs.channels.models import Channel
+from chatballs.identity.models import Organization
+from chatballs.products.models import Product
+
+migration = importlib.import_module(
+ "chatballs.channels.migrations.0005_enforce_policy_invariants"
+)
+
+
+class PolicyInvariantMigrationTests(TestCase):
+ def setUp(self) -> None:
+ self.organization = Organization.objects.create(name="Edevs", slug="edevs")
+ self.product = Product.objects.create(
+ organization=self.organization, code="foxray", name="FoxRay"
+ )
+
+ def _channel(self, code: str, **fields) -> Channel:
+ return Channel.objects.create(
+ organization=self.organization, code=code, name=code, **fields
+ )
+
+ def _run(self) -> None:
+ migration.relax_non_product_channels(apps, None)
+
+ def test_clears_commercial_flags_on_non_product_channels(self) -> None:
+ # Ровно случай канала edevs: непродуктовый, но с checkout и attribution.
+ violating = self._channel(
+ "edevs", allow_checkout_actions=True, allow_sales_attribution=True
+ )
+
+ self._run()
+
+ violating.refresh_from_db()
+ self.assertFalse(violating.allow_checkout_actions)
+ self.assertFalse(violating.allow_sales_attribution)
+ # Остальные флаги непродуктового канала не трогаются.
+ self.assertTrue(violating.allow_anonymous_sessions)
+ self.assertTrue(violating.allow_self_reported_contact)
+
+ def test_leaves_product_channels_untouched(self) -> None:
+ product_channel = self._channel(
+ "foxray-sales",
+ product=self.product,
+ allow_checkout_actions=True,
+ allow_sales_attribution=True,
+ )
+
+ self._run()
+
+ product_channel.refresh_from_db()
+ self.assertTrue(product_channel.allow_checkout_actions)
+ self.assertTrue(product_channel.allow_sales_attribution)
+
+ def test_stops_the_rollout_on_p3_p5_violations(self) -> None:
+ # Обязательная идентичность без продукта — смена смысла канала,
+ # выбирать за владельца нельзя, поэтому выкат останавливается.
+ self._channel("broken", requires_authenticated_product_identity=True)
+ untouched = self._channel(
+ "edevs", allow_checkout_actions=True, allow_sales_attribution=True
+ )
+
+ with self.assertRaises(RuntimeError) as error:
+ self._run()
+
+ self.assertIn("broken", str(error.exception))
+ untouched.refresh_from_db()
+ self.assertTrue(untouched.allow_checkout_actions)
+
+ def test_is_idempotent(self) -> None:
+ self._channel("edevs", allow_checkout_actions=True)
+
+ self._run()
+ self._run()
+
+ self.assertEqual(
+ Channel.objects.filter(
+ product__isnull=True, allow_checkout_actions=True
+ ).count(),
+ 0,
+ )
diff --git a/apps/backend/hub_platform/conversations/__init__.py b/apps/backend/chatballs/conversations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/__init__.py
rename to apps/backend/chatballs/conversations/__init__.py
diff --git a/apps/backend/chatballs/conversations/admin.py b/apps/backend/chatballs/conversations/admin.py
new file mode 100644
index 0000000..ed848a6
--- /dev/null
+++ b/apps/backend/chatballs/conversations/admin.py
@@ -0,0 +1,21 @@
+from django.contrib import admin
+
+from chatballs.conversations.models import Contact, Conversation, Message
+
+
+@admin.register(Conversation)
+class ConversationAdmin(admin.ModelAdmin):
+ list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")
+ list_filter = ("lifecycle", "control_mode")
+
+
+@admin.register(Contact)
+class ContactAdmin(admin.ModelAdmin):
+ list_display = ("id", "name", "organization", "created_at")
+ search_fields = ("name",)
+
+
+@admin.register(Message)
+class MessageAdmin(admin.ModelAdmin):
+ list_display = ("id", "conversation", "author_type", "created_at")
+ list_filter = ("author_type",)
diff --git a/apps/backend/chatballs/conversations/apps.py b/apps/backend/chatballs/conversations/apps.py
new file mode 100644
index 0000000..130ba7d
--- /dev/null
+++ b/apps/backend/chatballs/conversations/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class ConversationsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ label = "conversations"
+ name = "chatballs.conversations"
+ verbose_name = "Conversations (contacts, dialogs, messages)"
diff --git a/apps/backend/hub_platform/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py
similarity index 97%
rename from apps/backend/hub_platform/conversations/chat_extras_views.py
rename to apps/backend/chatballs/conversations/chat_extras_views.py
index 2968864..e45e674 100644
--- a/apps/backend/hub_platform/conversations/chat_extras_views.py
+++ b/apps/backend/chatballs/conversations/chat_extras_views.py
@@ -13,8 +13,8 @@ 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.conversations.models import (
+from chatballs.api.permissions import HasCapability
+from chatballs.conversations.models import (
Conversation,
ConversationLabel,
ConversationPriority,
@@ -22,13 +22,13 @@ from hub_platform.conversations.models import (
LifecycleState,
ReplyTemplate,
)
-from hub_platform.conversations.selectors import apply_conversation_visibility
-from hub_platform.conversations.serializers import conversation_payload
-from hub_platform.conversations.view_base import ConversationViewBase
-from hub_platform.identity.avatars import user_avatar_url
-from hub_platform.identity.group_models import EmployeeGroup
-from hub_platform.identity.models import HumanUser, OrganizationMembership
-from hub_platform.identity.policy import can_administer_access
+from chatballs.conversations.selectors import apply_conversation_visibility
+from chatballs.conversations.serializers import conversation_payload
+from chatballs.conversations.view_base import ConversationViewBase
+from chatballs.identity.avatars import user_avatar_url
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.identity.models import HumanUser, OrganizationMembership
+from chatballs.identity.policy import can_administer_access
def _label_payload(label: ConversationLabel) -> dict[str, object]:
diff --git a/apps/backend/hub_platform/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py
similarity index 98%
rename from apps/backend/hub_platform/conversations/clients.py
rename to apps/backend/chatballs/conversations/clients.py
index 655ac9c..627ebb7 100644
--- a/apps/backend/hub_platform/conversations/clients.py
+++ b/apps/backend/chatballs/conversations/clients.py
@@ -8,14 +8,14 @@ from __future__ import annotations
from django.db.models import Prefetch, Q
-from hub_platform.conversations.models import (
+from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
Conversation,
ControlMode,
LifecycleState,
)
-from hub_platform.identity.models import AuditEvent
+from chatballs.identity.models import AuditEvent
# Короткие коды для UI (совпадают с фронтовыми справочниками).
PROVIDER_CODE = {
diff --git a/apps/backend/hub_platform/conversations/html_sanitizer.py b/apps/backend/chatballs/conversations/html_sanitizer.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/html_sanitizer.py
rename to apps/backend/chatballs/conversations/html_sanitizer.py
diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py
similarity index 95%
rename from apps/backend/hub_platform/conversations/ingest.py
rename to apps/backend/chatballs/conversations/ingest.py
index 96d0859..6a8176e 100644
--- a/apps/backend/hub_platform/conversations/ingest.py
+++ b/apps/backend/chatballs/conversations/ingest.py
@@ -12,12 +12,12 @@ import logging
from django.db import IntegrityError, transaction
from django.utils import timezone
-from hub_platform.ai.limits import LimitExceeded
-from hub_platform.ai.provider.base import ProviderError
-from hub_platform.ai.runtime import HANDOFF_TOKEN
-from hub_platform.channels.runtime import run_channel_turn
-from hub_platform.conversations import transports
-from hub_platform.conversations.models import (
+from chatballs.ai.limits import LimitExceeded
+from chatballs.ai.provider.base import ProviderError
+from chatballs.ai.runtime import HANDOFF_TOKEN
+from chatballs.channels.runtime import run_channel_turn
+from chatballs.conversations import transports
+from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
@@ -28,11 +28,11 @@ from hub_platform.conversations.models import (
MessageAuthor,
MessageKind,
)
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.events.models import EventOwnership, InboxEvent
-from hub_platform.notifications.models import NotificationAudience, NotificationType
-from hub_platform.notifications.services import notify, notify_management
-from hub_platform.tenancy.context import TenantContext
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.events.models import EventOwnership, InboxEvent
+from chatballs.notifications.models import NotificationAudience, NotificationType
+from chatballs.notifications.services import notify, notify_management
+from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
diff --git a/apps/backend/chatballs/conversations/maintenance.py b/apps/backend/chatballs/conversations/maintenance.py
new file mode 100644
index 0000000..defb740
--- /dev/null
+++ b/apps/backend/chatballs/conversations/maintenance.py
@@ -0,0 +1,25 @@
+import logging
+from datetime import timedelta
+
+from django.utils import timezone
+
+from chatballs.conversations.models import Conversation, LifecycleState
+
+logger = logging.getLogger(__name__)
+
+# ADR-HUB-0002: семь дней без активности переводят OPEN в CLOSED.
+AUTOCLOSE_DAYS = 7
+
+
+def close_stale_conversations(context, days: int = AUTOCLOSE_DAYS) -> int:
+ cutoff = timezone.now() - timedelta(days=days)
+ closed = Conversation.objects.filter(
+ organization=context.organization,
+ lifecycle=LifecycleState.OPEN,
+ last_activity_at__lte=cutoff,
+ ).update(
+ lifecycle=LifecycleState.CLOSED
+ )
+ if closed:
+ logger.info("Auto-closed %s stale conversations (>%sd inactive)", closed, days)
+ return closed
diff --git a/apps/backend/hub_platform/conversations/migrations/0001_initial.py b/apps/backend/chatballs/conversations/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0001_initial.py
rename to apps/backend/chatballs/conversations/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py b/apps/backend/chatballs/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py
rename to apps/backend/chatballs/conversations/migrations/0002_conversation_support_identity_snapshot_and_more.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0003_connectionidentity_username_contact_phone_and_more.py b/apps/backend/chatballs/conversations/migrations/0003_connectionidentity_username_contact_phone_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0003_connectionidentity_username_contact_phone_and_more.py
rename to apps/backend/chatballs/conversations/migrations/0003_connectionidentity_username_contact_phone_and_more.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0004_conversationread.py b/apps/backend/chatballs/conversations/migrations/0004_conversationread.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0004_conversationread.py
rename to apps/backend/chatballs/conversations/migrations/0004_conversationread.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0005_connectionidentity_organization_and_more.py b/apps/backend/chatballs/conversations/migrations/0005_connectionidentity_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0005_connectionidentity_organization_and_more.py
rename to apps/backend/chatballs/conversations/migrations/0005_connectionidentity_organization_and_more.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py b/apps/backend/chatballs/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py
rename to apps/backend/chatballs/conversations/migrations/0006_alter_connectionidentity_organization_and_more.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0007_conversation_transport_meta.py b/apps/backend/chatballs/conversations/migrations/0007_conversation_transport_meta.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0007_conversation_transport_meta.py
rename to apps/backend/chatballs/conversations/migrations/0007_conversation_transport_meta.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py b/apps/backend/chatballs/conversations/migrations/0008_message_content_html.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py
rename to apps/backend/chatballs/conversations/migrations/0008_message_content_html.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0009_contact_avatar_url.py b/apps/backend/chatballs/conversations/migrations/0009_contact_avatar_url.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0009_contact_avatar_url.py
rename to apps/backend/chatballs/conversations/migrations/0009_contact_avatar_url.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0010_conversation_group.py b/apps/backend/chatballs/conversations/migrations/0010_conversation_group.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0010_conversation_group.py
rename to apps/backend/chatballs/conversations/migrations/0010_conversation_group.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py b/apps/backend/chatballs/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py
rename to apps/backend/chatballs/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py
diff --git a/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py b/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py
new file mode 100644
index 0000000..252103b
--- /dev/null
+++ b/apps/backend/chatballs/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py
@@ -0,0 +1,44 @@
+# Generated by Django 5.2.15 on 2026-09-04 20:40
+
+import chatballs.conversations.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('conversations', '0011_conversation_archived_at_conversation_note_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='message',
+ name='audio',
+ field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),
+ ),
+ migrations.AddField(
+ model_name='message',
+ name='audio_content_type',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='message',
+ name='duration_seconds',
+ field=models.PositiveIntegerField(default=0),
+ ),
+ migrations.AddField(
+ model_name='message',
+ name='transcript',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='message',
+ name='transcript_status',
+ field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),
+ ),
+ migrations.AlterField(
+ model_name='message',
+ name='kind',
+ field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),
+ ),
+ ]
diff --git a/apps/backend/hub_platform/conversations/migrations/0013_message_conv_message_text_fts.py b/apps/backend/chatballs/conversations/migrations/0013_message_conv_message_text_fts.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0013_message_conv_message_text_fts.py
rename to apps/backend/chatballs/conversations/migrations/0013_message_conv_message_text_fts.py
diff --git a/apps/backend/hub_platform/conversations/migrations/0014_contact_card_fields.py b/apps/backend/chatballs/conversations/migrations/0014_contact_card_fields.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/0014_contact_card_fields.py
rename to apps/backend/chatballs/conversations/migrations/0014_contact_card_fields.py
diff --git a/apps/backend/hub_platform/conversations/migrations/__init__.py b/apps/backend/chatballs/conversations/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/conversations/migrations/__init__.py
rename to apps/backend/chatballs/conversations/migrations/__init__.py
diff --git a/apps/backend/hub_platform/conversations/models.py b/apps/backend/chatballs/conversations/models.py
similarity index 99%
rename from apps/backend/hub_platform/conversations/models.py
rename to apps/backend/chatballs/conversations/models.py
index bce9ee4..5d48101 100644
--- a/apps/backend/hub_platform/conversations/models.py
+++ b/apps/backend/chatballs/conversations/models.py
@@ -3,7 +3,7 @@ from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector
from django.db import models
-from hub_platform.tenancy.models import TenantRelationModel
+from chatballs.tenancy.models import TenantRelationModel
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
# разделено на независимые оси; перехват оператором — атомарный.
diff --git a/apps/backend/chatballs/conversations/poller.py b/apps/backend/chatballs/conversations/poller.py
new file mode 100644
index 0000000..23a6d72
--- /dev/null
+++ b/apps/backend/chatballs/conversations/poller.py
@@ -0,0 +1,38 @@
+import logging
+
+from chatballs.conversations import transports
+from chatballs.conversations.ingest import ingest_inbound
+from chatballs.integrations.models import Integration
+
+logger = logging.getLogger(__name__)
+
+
+def poll_all_messengers(context) -> int:
+ """Poll every messenger connection bound to a channel; ingest inbound. Returns count."""
+ # Сервисные боты уведомлений поллятся отдельно (notifications.binding).
+ # Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки
+ # без ключа purpose (NULL в SQL), т.е. все клиентские боты.
+ integrations = [
+ integration
+ for integration in Integration.objects.filter(
+ organization=context.organization,
+ provider__in=transports.SUPPORTED_PROVIDERS,
+ is_active=True,
+ channel__isnull=False,
+ channel__is_active=True,
+ ).exclude(secret="")
+ if integration.config.get("purpose") != "notifications"
+ ]
+ total = 0
+ for integration in integrations:
+ messages, new_marker = transports.poll(integration)
+ for inbound in messages:
+ try:
+ ingest_inbound(integration, inbound)
+ total += 1
+ except Exception: # pragma: no cover
+ logger.exception("Ingest failed for integration %s", integration.id)
+ if new_marker and new_marker != integration.poll_marker:
+ integration.poll_marker = new_marker
+ integration.save(update_fields=["poll_marker", "updated_at"])
+ return total
diff --git a/apps/backend/hub_platform/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py
similarity index 78%
rename from apps/backend/hub_platform/conversations/reporting_views.py
rename to apps/backend/chatballs/conversations/reporting_views.py
index 60071c0..f1bf08b 100644
--- a/apps/backend/hub_platform/conversations/reporting_views.py
+++ b/apps/backend/chatballs/conversations/reporting_views.py
@@ -1,10 +1,10 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from hub_platform.conversations.clients import client_detail, clients_overview
-from hub_platform.conversations.models import Contact
-from hub_platform.conversations.stats import sales_overview_stats
-from hub_platform.conversations.view_base import ConversationViewBase
+from chatballs.conversations.clients import client_detail, clients_overview
+from chatballs.conversations.models import Contact
+from chatballs.conversations.stats import sales_overview_stats
+from chatballs.conversations.view_base import ConversationViewBase
class ConversationStatsView(ConversationViewBase):
diff --git a/apps/backend/hub_platform/conversations/selectors.py b/apps/backend/chatballs/conversations/selectors.py
similarity index 93%
rename from apps/backend/hub_platform/conversations/selectors.py
rename to apps/backend/chatballs/conversations/selectors.py
index 8ba7981..406adc3 100644
--- a/apps/backend/hub_platform/conversations/selectors.py
+++ b/apps/backend/chatballs/conversations/selectors.py
@@ -1,8 +1,8 @@
from django.db.models import F, Max, Q, QuerySet
-from hub_platform.conversations.models import Conversation
-from hub_platform.identity.policy import conversation_visibility
-from hub_platform.tenancy.context import TenantContext
+from chatballs.conversations.models import Conversation
+from chatballs.identity.policy import conversation_visibility
+from chatballs.tenancy.context import TenantContext
def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
diff --git a/apps/backend/hub_platform/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py
similarity index 97%
rename from apps/backend/hub_platform/conversations/serializers.py
rename to apps/backend/chatballs/conversations/serializers.py
index c51d4c9..ed5cf93 100644
--- a/apps/backend/hub_platform/conversations/serializers.py
+++ b/apps/backend/chatballs/conversations/serializers.py
@@ -1,6 +1,6 @@
-from hub_platform.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor
-from hub_platform.identity.avatars import user_avatar_url_in
-from hub_platform.integrations.models import IntegrationProvider
+from chatballs.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor
+from chatballs.identity.avatars import user_avatar_url_in
+from chatballs.integrations.models import IntegrationProvider
def message_payload(message: Message) -> dict[str, object]:
diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py
new file mode 100644
index 0000000..522957c
--- /dev/null
+++ b/apps/backend/chatballs/conversations/services.py
@@ -0,0 +1,205 @@
+from django.db import transaction
+from django.utils import timezone
+
+from chatballs.conversations import transports
+from chatballs.conversations.models import (
+ ConnectionIdentity,
+ Conversation,
+ ControlMode,
+ ExpectedResponder,
+ LifecycleState,
+ Message,
+ MessageAuthor,
+ MessageKind,
+)
+from chatballs.identity.models import EmployeeRole
+from chatballs.integrations.models import IntegrationProvider
+from chatballs.tenancy.context import TenantContext
+
+CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
+CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
+
+
+class ClaimError(Exception):
+ pass
+
+
+def _require_open(conversation: Conversation) -> None:
+ if conversation.lifecycle != LifecycleState.OPEN:
+ raise ClaimError("Диалог закрыт")
+
+
+def _operator_label(operator) -> str:
+ return getattr(operator, "full_name", "") or operator.email
+
+
+@transaction.atomic
+def claim_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
+ # Атомарный перехват у AI (ADR-HUB-0003): только один оператор забирает диалог.
+ conversation = Conversation.objects.select_for_update().get(
+ id=conversation_id, organization=context.organization
+ )
+ return claim_locked_conversation(context=context, conversation=conversation)
+
+
+def claim_locked_conversation(*, context: TenantContext, conversation: Conversation) -> Conversation:
+ """Claim an already locked conversation inside the caller's transaction."""
+ operator = context.actor_user
+ if operator is None or conversation.organization_id != context.organization_id:
+ raise ClaimError("Диалог недоступен")
+ _require_open(conversation)
+ # Владелец (OWNER) может перехватить диалог у любого оператора; прочие сотрудники
+ # не могут забрать диалог, уже назначенный другому оператору.
+ is_owner = context.membership is not None and context.membership.role == EmployeeRole.OWNER
+ if (
+ not is_owner
+ and conversation.control_mode == ControlMode.HUMAN
+ and conversation.assigned_operator_id
+ and conversation.assigned_operator_id != operator.id
+ ):
+ raise ClaimError("Диалог уже ведёт другой оператор")
+ conversation.control_mode = ControlMode.HUMAN
+ conversation.assigned_operator = operator
+ conversation.expected_responder = ExpectedResponder.OPERATOR
+ conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
+ Message.objects.create(
+ conversation=conversation,
+ author_type=MessageAuthor.SYSTEM,
+ text=f"Оператор {_operator_label(operator)} перехватил диалог",
+ )
+ return conversation
+
+
+@transaction.atomic
+def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversation:
+ conversation = Conversation.objects.select_for_update().get(
+ id=conversation_id, organization=context.organization
+ )
+ _require_open(conversation)
+ agent = getattr(conversation.channel, "ai_agent", None)
+ if agent is None or not agent.is_active:
+ raise ClaimError("У канала нет активного AI-агента")
+ conversation.control_mode = ControlMode.AI
+ conversation.assigned_operator = None
+ conversation.expected_responder = ExpectedResponder.AI
+ conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
+ Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
+ return conversation
+
+
+@transaction.atomic
+def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversation:
+ # Оператор возвращает диалог в общую очередь (ADR-HUB-0003): снят с себя, ждёт оператора.
+ conversation = Conversation.objects.select_for_update().get(
+ id=conversation_id, organization=context.organization
+ )
+ _require_open(conversation)
+ conversation.control_mode = ControlMode.PAUSED
+ conversation.assigned_operator = None
+ conversation.expected_responder = ExpectedResponder.OPERATOR
+ conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
+ Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
+ return conversation
+
+
+def post_operator_message(
+ *, context: TenantContext, conversation: Conversation, text: str
+) -> Message:
+ operator = context.actor_user
+ if operator is None or conversation.organization_id != context.organization_id:
+ raise Conversation.DoesNotExist
+ _require_open(conversation)
+ message = Message.objects.create(
+ conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
+ )
+ conversation.last_activity_at = timezone.now()
+ conversation.expected_responder = ExpectedResponder.CUSTOMER
+ conversation.save(update_fields=["last_activity_at", "expected_responder"])
+ # Отправляем в тот же мессенджер, откуда пришёл клиент.
+ if conversation.connection_id:
+ identity = ConnectionIdentity.objects.filter(
+ connection=conversation.connection, contact=conversation.contact
+ ).first()
+ transports.send_reply(
+ conversation.connection,
+ chat_id=conversation.external_chat_id,
+ user_id=identity.external_user_id if identity else "",
+ text=text,
+ )
+ return message
+
+
+def request_contact(*, context: TenantContext, conversation: Conversation) -> Message:
+ """Запрос контакта у клиента: TG/MAX — сообщение с кнопкой «Поделиться
+ контактом», Web — виджет рисует форму телефона по kind=contact_request."""
+ operator = context.actor_user
+ if operator is None or conversation.organization_id != context.organization_id:
+ raise Conversation.DoesNotExist
+ _require_open(conversation)
+ is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
+ text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
+ message = Message.objects.create(
+ conversation=conversation,
+ author_type=MessageAuthor.OPERATOR,
+ author_user=operator,
+ kind=MessageKind.CONTACT_REQUEST,
+ text=text,
+ )
+ conversation.last_activity_at = timezone.now()
+ conversation.expected_responder = ExpectedResponder.CUSTOMER
+ conversation.save(update_fields=["last_activity_at", "expected_responder"])
+ if conversation.connection_id:
+ identity = ConnectionIdentity.objects.filter(
+ connection=conversation.connection, contact=conversation.contact
+ ).first()
+ transports.send_contact_request(
+ conversation.connection,
+ chat_id=conversation.external_chat_id,
+ user_id=identity.external_user_id if identity else "",
+ text=text,
+ )
+ return message
+
+
+@transaction.atomic
+def close_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
+ conversation = Conversation.objects.select_for_update().get(
+ id=conversation_id, organization=context.organization
+ )
+ _require_open(conversation)
+ conversation.lifecycle = LifecycleState.CLOSED
+ conversation.control_mode = ControlMode.PAUSED
+ conversation.assigned_operator = None
+ conversation.expected_responder = ExpectedResponder.NOBODY
+ conversation.save(
+ update_fields=[
+ "lifecycle",
+ "control_mode",
+ "assigned_operator",
+ "expected_responder",
+ ]
+ )
+ return conversation
+
+
+@transaction.atomic
+def mark_conversation_as_spam(
+ *, context: TenantContext, conversation_id: int
+) -> Conversation:
+ conversation = Conversation.objects.select_for_update().get(
+ id=conversation_id, organization=context.organization
+ )
+ _require_open(conversation)
+ conversation.lifecycle = LifecycleState.SPAM
+ conversation.control_mode = ControlMode.PAUSED
+ conversation.assigned_operator = None
+ conversation.expected_responder = ExpectedResponder.NOBODY
+ conversation.save(
+ update_fields=[
+ "lifecycle",
+ "control_mode",
+ "assigned_operator",
+ "expected_responder",
+ ]
+ )
+ return conversation
diff --git a/apps/backend/hub_platform/conversations/stats.py b/apps/backend/chatballs/conversations/stats.py
similarity index 97%
rename from apps/backend/hub_platform/conversations/stats.py
rename to apps/backend/chatballs/conversations/stats.py
index a0dc661..f6b27be 100644
--- a/apps/backend/hub_platform/conversations/stats.py
+++ b/apps/backend/chatballs/conversations/stats.py
@@ -12,9 +12,9 @@ from django.db.models import Count, Sum
from django.db.models.functions import TruncDate, TruncHour
from django.utils import timezone
-from hub_platform.ai.models import LlmInvocation
-from hub_platform.channels.selectors import channels_in_organization
-from hub_platform.conversations.models import (
+from chatballs.ai.models import LlmInvocation
+from chatballs.channels.selectors import channels_in_organization
+from chatballs.conversations.models import (
Conversation,
ControlMode,
ExpectedResponder,
diff --git a/apps/backend/hub_platform/conversations/test_authorization.py b/apps/backend/chatballs/conversations/test_authorization.py
similarity index 94%
rename from apps/backend/hub_platform/conversations/test_authorization.py
rename to apps/backend/chatballs/conversations/test_authorization.py
index 78b1245..9d08095 100644
--- a/apps/backend/hub_platform/conversations/test_authorization.py
+++ b/apps/backend/chatballs/conversations/test_authorization.py
@@ -1,10 +1,10 @@
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.models import Contact, Conversation
-from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember
-from hub_platform.identity.models import (
+from chatballs.channels.models import Channel
+from chatballs.conversations.models import Contact, Conversation
+from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
diff --git a/apps/backend/hub_platform/conversations/test_chat_extras.py b/apps/backend/chatballs/conversations/test_chat_extras.py
similarity index 97%
rename from apps/backend/hub_platform/conversations/test_chat_extras.py
rename to apps/backend/chatballs/conversations/test_chat_extras.py
index 54cf66b..15c4808 100644
--- a/apps/backend/hub_platform/conversations/test_chat_extras.py
+++ b/apps/backend/chatballs/conversations/test_chat_extras.py
@@ -2,10 +2,10 @@ import json
from django.test import TestCase
from django.utils import timezone
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.models import (
+from chatballs.channels.models import Channel
+from chatballs.conversations.models import (
Contact,
Conversation,
ConversationLabel,
@@ -14,8 +14,8 @@ from hub_platform.conversations.models import (
LifecycleState,
ReplyTemplate,
)
-from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember
-from hub_platform.identity.models import (
+from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
@@ -351,7 +351,7 @@ class LaunchChecklistTests(TestCase):
channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
- from hub_platform.integrations.models import (
+ from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
diff --git a/apps/backend/chatballs/conversations/test_email_transport.py b/apps/backend/chatballs/conversations/test_email_transport.py
new file mode 100644
index 0000000..8980e55
--- /dev/null
+++ b/apps/backend/chatballs/conversations/test_email_transport.py
@@ -0,0 +1,283 @@
+"""Email-транспорт: разбор писем, UID-курсор, тредирование (SPEC-HUB-0025 §5)."""
+
+from email import message_from_bytes, policy
+from email.message import EmailMessage as MimeMessage
+from unittest import mock
+
+from django.test import TestCase
+
+from chatballs.channels.models import Channel
+from chatballs.conversations.models import Contact, Conversation, MessageAuthor
+from chatballs.conversations.clients import client_detail, clients_overview
+from chatballs.conversations.serializers import conversation_payload
+from chatballs.conversations.transports import email as email_transport
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
+
+EMAIL_CONFIG = {
+ "email": "support@edevs.tech",
+ "imap_host": "imap.test",
+ "imap_port": 993,
+ "imap_ssl": True,
+ "smtp_host": "smtp.test",
+ "smtp_port": 465,
+ "smtp_ssl": True,
+}
+
+
+def _raw(*, from_="Иван Петров ", subject="Вопрос по FoxRay", text="Здравствуйте!", message_id="", html=None, attach=False) -> bytes:
+ message = MimeMessage()
+ message["From"] = from_
+ message["Subject"] = subject
+ if message_id:
+ message["Message-ID"] = message_id
+ if html is not None:
+ message.add_alternative(html, subtype="html")
+ else:
+ message.set_content(text)
+ if attach:
+ message.add_attachment(b"%PDF", maintype="application", subtype="pdf", filename="doc.pdf")
+ return message.as_bytes()
+
+
+def _parsed(raw: bytes):
+ return message_from_bytes(raw, policy=policy.default)
+
+
+def _integration(**overrides) -> Integration:
+ fields = {
+ "kind": IntegrationKind.MESSENGER,
+ "provider": IntegrationProvider.EMAIL,
+ "name": "Почта",
+ "secret": "app-password",
+ "config": dict(EMAIL_CONFIG),
+ "poll_marker": "",
+ }
+ fields.update(overrides)
+ return Integration(**fields)
+
+
+class EmailNormalizeTests(TestCase):
+ def test_plain_message_becomes_inbound_with_thread_meta(self) -> None:
+ inbound, message_id = email_transport._normalize(
+ _parsed(_raw()), own_address="support@edevs.tech", fallback_id="7:100"
+ )
+ self.assertIsNotNone(inbound)
+ self.assertEqual(inbound.external_id, "")
+ self.assertEqual(inbound.user_id, "ivan@example.com")
+ self.assertEqual(inbound.chat_id, "ivan@example.com")
+ self.assertEqual(inbound.display_name, "Иван Петров")
+ self.assertEqual(inbound.text, "Здравствуйте!")
+ self.assertEqual(inbound.thread_meta, {"subject": "Вопрос по FoxRay", "last_message_id": ""})
+ self.assertEqual(message_id, "")
+
+ def test_own_message_is_skipped(self) -> None:
+ inbound, _ = email_transport._normalize(
+ _parsed(_raw(from_="support@edevs.tech")), own_address="support@edevs.tech", fallback_id="7:100"
+ )
+ self.assertIsNone(inbound)
+
+ def test_html_only_body_is_flattened_to_text(self) -> None:
+ inbound, _ = email_transport._normalize(
+ _parsed(_raw(html="Добрый день!
Сколько стоит «FoxRay»?
")),
+ own_address="support@edevs.tech",
+ fallback_id="7:100",
+ )
+ self.assertIsNotNone(inbound)
+ self.assertIn("Добрый день!", inbound.text)
+ self.assertIn("«FoxRay»", inbound.text)
+ self.assertNotIn("", inbound.text)
+ self.assertIn("
Добрый день!
", inbound.content_html)
+
+ def test_html_body_is_sanitized_before_ingest(self) -> None:
+ inbound, _ = email_transport._normalize(
+ _parsed(
+ _raw(
+ html=(
+ 'Здравствуйте!
'
+ ''
+ 'опасная ссылка'
+ 'сайт'
+ )
+ )
+ ),
+ own_address="support@edevs.tech",
+ fallback_id="7:100",
+ )
+ self.assertIsNotNone(inbound)
+ self.assertNotIn("onclick", inbound.content_html)
+ self.assertNotIn("script", inbound.content_html)
+ self.assertNotIn("alert", inbound.content_html)
+ self.assertNotIn("javascript:", inbound.content_html)
+ self.assertIn('href="https://example.com/path"', inbound.content_html)
+
+ def test_attachments_add_note(self) -> None:
+ inbound, _ = email_transport._normalize(
+ _parsed(_raw(attach=True)), own_address="support@edevs.tech", fallback_id="7:100"
+ )
+ self.assertIn("[Вложения не поддерживаются: 1 файл(ов)]", inbound.text)
+
+ def test_missing_message_id_falls_back_to_uid(self) -> None:
+ inbound, _ = email_transport._normalize(
+ _parsed(_raw(message_id="")), own_address="support@edevs.tech", fallback_id="7:100"
+ )
+ self.assertEqual(inbound.external_id, "7:100")
+
+
+class _FakeImap:
+ def __init__(self, *, validity: int, next_uid: int, mailbox: dict[int, bytes]):
+ self.validity = validity
+ self.next_uid = next_uid
+ self.mailbox = mailbox
+
+ def select(self, name, readonly=False):
+ return "OK", [b""]
+
+ def status(self, name, item):
+ value = self.validity if "UIDVALIDITY" in item else self.next_uid
+ key = "UIDVALIDITY" if "UIDVALIDITY" in item else "UIDNEXT"
+ return "OK", [f"INBOX ({key} {value})".encode()]
+
+ def uid(self, command, *args):
+ if command == "SEARCH":
+ # Реальный IMAP на «N:*» всегда возвращает и старшее письмо.
+ return "OK", [" ".join(str(u) for u in sorted(self.mailbox)).encode() or b""]
+ return "OK", [(b"1 (RFC822 {0}", self.mailbox[int(args[0])]), b")"]
+
+ def logout(self):
+ return "BYE", []
+
+
+class EmailPollTests(TestCase):
+ def _poll(self, integration, fake):
+ with mock.patch.object(email_transport, "_imap_connect", return_value=fake):
+ return email_transport.poll_updates(integration)
+
+ def test_first_run_sets_cursor_without_ingesting_history(self) -> None:
+ fake = _FakeImap(validity=7, next_uid=100, mailbox={98: _raw(), 99: _raw()})
+ messages, marker = self._poll(_integration(poll_marker=""), fake)
+ self.assertEqual(messages, [])
+ self.assertEqual(marker, "7:99")
+
+ def test_new_mail_after_cursor_is_ingested(self) -> None:
+ fake = _FakeImap(validity=7, next_uid=101, mailbox={99: _raw(message_id=""), 100: _raw()})
+ messages, marker = self._poll(_integration(poll_marker="7:99"), fake)
+ self.assertEqual([m.external_id for m in messages], [""])
+ self.assertEqual(marker, "7:100")
+
+ def test_uidvalidity_change_resets_cursor(self) -> None:
+ fake = _FakeImap(validity=8, next_uid=50, mailbox={49: _raw()})
+ messages, marker = self._poll(_integration(poll_marker="7:99"), fake)
+ self.assertEqual(messages, [])
+ self.assertEqual(marker, "8:49")
+
+ def test_connection_error_keeps_cursor(self) -> None:
+ integration = _integration(poll_marker="7:99")
+ with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
+ messages, marker = email_transport.poll_updates(integration)
+ self.assertEqual(messages, [])
+ self.assertEqual(marker, "7:99")
+
+
+class EmailSendTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.organization = Organization.objects.get(slug="edevs")
+ self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
+ self.integration = _integration(organization=self.organization, channel=self.channel)
+ self.integration.save()
+ contact = Contact.objects.create(organization=self.organization, name="Иван")
+ self.conversation = Conversation.objects.create(
+ organization=self.organization,
+ channel=self.channel,
+ connection=self.integration,
+ contact=contact,
+ external_chat_id="ivan@example.com",
+ transport_meta={"subject": "Вопрос по FoxRay", "last_message_id": ""},
+ )
+
+ def test_reply_goes_into_the_same_thread(self) -> None:
+ with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:
+ ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")
+ self.assertTrue(ok)
+ smtp = smtp_cls.return_value
+ smtp.login.assert_called_once_with("support@edevs.tech", "app-password")
+ outgoing = smtp.send_message.call_args.args[0]
+ self.assertEqual(outgoing["To"], "ivan@example.com")
+ self.assertEqual(outgoing["Subject"], "Re: Вопрос по FoxRay")
+ self.assertEqual(outgoing["In-Reply-To"], "")
+ self.assertEqual(outgoing["References"], "")
+
+ def test_send_without_thread_meta_uses_fallback_subject(self) -> None:
+ self.conversation.transport_meta = {}
+ self.conversation.save(update_fields=["transport_meta"])
+ with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:
+ ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")
+ self.assertTrue(ok)
+ outgoing = smtp_cls.return_value.send_message.call_args.args[0]
+ self.assertEqual(outgoing["Subject"], "Re: Ваше обращение")
+ self.assertIsNone(outgoing["In-Reply-To"])
+
+ def test_smtp_failure_returns_false(self) -> None:
+ with mock.patch.object(email_transport.smtplib, "SMTP_SSL", side_effect=OSError("refused")):
+ ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Привет")
+ self.assertFalse(ok)
+
+
+class EmailIngestThreadMetaTests(TestCase):
+ """ingest пишет transport_meta: тема — от первого письма, Message-ID — последний."""
+
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.organization = Organization.objects.get(slug="edevs")
+ self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
+ self.integration = _integration(organization=self.organization, channel=self.channel)
+ self.integration.save()
+
+ def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:
+ from chatballs.conversations.ingest import ingest_inbound
+ from chatballs.conversations.transports.base import InboundMessage
+
+ inbound = InboundMessage(
+ external_id=external_id,
+ user_id="ivan@example.com",
+ chat_id="ivan@example.com",
+ text="Здравствуйте!",
+ display_name="Иван",
+ thread_meta={"subject": subject, "last_message_id": message_id},
+ )
+ with (
+ mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")),
+ mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
+ ):
+ ingest_inbound(self.integration, inbound)
+
+ def test_subject_pinned_to_first_message_id_follows_last(self) -> None:
+ self._ingest(external_id="", subject="Вопрос по FoxRay", message_id="")
+ self._ingest(external_id="", subject="Re: Вопрос по FoxRay", message_id="")
+ conversation = Conversation.objects.get(channel=self.channel)
+ self.assertEqual(conversation.transport_meta["subject"], "Вопрос по FoxRay")
+ self.assertEqual(conversation.transport_meta["last_message_id"], "")
+ authors = list(conversation.messages.values_list("author_type", flat=True))
+ self.assertIn(MessageAuthor.CONTACT, authors)
+
+ def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None:
+ self._ingest(
+ external_id="",
+ subject="Контакты",
+ message_id="",
+ )
+ conversation = Conversation.objects.get(channel=self.channel)
+ dialog = conversation_payload(conversation, with_messages=True)
+ self.assertEqual(dialog["connection"]["provider"], "EMAIL")
+ self.assertEqual(dialog["contact"]["email"], "ivan@example.com")
+ self.assertEqual(dialog["messages"][0]["contentHtml"], "")
+
+ overview = clients_overview(self.organization.id)
+ self.assertEqual(overview[0]["email"], "ivan@example.com")
+ self.assertIn("EMAIL", overview[0]["channels"])
+
+ detail = client_detail(self.organization.id, conversation.contact_id)
+ self.assertEqual(detail["email"], "ivan@example.com")
+ self.assertEqual(detail["identities"][0]["value"], "ivan@example.com")
diff --git a/apps/backend/chatballs/conversations/test_lifecycle.py b/apps/backend/chatballs/conversations/test_lifecycle.py
new file mode 100644
index 0000000..fd3da31
--- /dev/null
+++ b/apps/backend/chatballs/conversations/test_lifecycle.py
@@ -0,0 +1,190 @@
+import json
+from unittest import mock
+
+from django.test import TestCase
+
+from chatballs.channels.models import Channel
+from chatballs.conversations.ingest import ingest_inbound
+from chatballs.conversations.models import (
+ ConnectionIdentity,
+ Contact,
+ ControlMode,
+ Conversation,
+ ExpectedResponder,
+ LifecycleState,
+ MessageAuthor,
+)
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import HumanUser, Organization
+from chatballs.integrations.models import (
+ Integration,
+ IntegrationKind,
+ IntegrationProvider,
+)
+from chatballs.testing import TenantAPIClient as APIClient
+
+
+def _connection(channel: Channel) -> Integration:
+ return Integration.objects.create(
+ organization=channel.organization,
+ kind=IntegrationKind.MESSENGER,
+ provider=IntegrationProvider.TELEGRAM,
+ name="test-bot",
+ channel=channel,
+ )
+
+
+class OperatorOnlyIngestTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(
+ email="owner@edevs.tech", password="temporary-password"
+ )
+ self.organization = Organization.objects.get(slug="edevs")
+ self.channel = Channel.objects.create(
+ organization=self.organization,
+ code="operator-only",
+ name="Операторский канал",
+ )
+ self.integration = _connection(self.channel)
+
+ def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
+ with (
+ mock.patch(
+ "chatballs.conversations.ingest.run_channel_turn"
+ ) as ai_turn,
+ mock.patch(
+ "chatballs.conversations.ingest.transports.send_reply"
+ ) as send,
+ ):
+ ingest_inbound(self.integration, inbound)
+ return ai_turn, send
+
+ def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
+ ai_turn, send = self._ingest_without_ai(
+ InboundMessage(
+ external_id="operator-1",
+ user_id="user-1",
+ chat_id="chat-1",
+ text="Нужна помощь",
+ display_name="Гость",
+ )
+ )
+
+ conversation = self.channel.conversations.get()
+ self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
+ self.assertEqual(
+ conversation.expected_responder, ExpectedResponder.OPERATOR
+ )
+ self.assertEqual(
+ list(conversation.messages.values_list("author_type", flat=True)),
+ [MessageAuthor.CONTACT],
+ )
+ ai_turn.assert_not_called()
+ send.assert_not_called()
+
+ def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None:
+ contact = Contact.objects.create(
+ organization=self.organization, name="Клиент"
+ )
+ ConnectionIdentity.objects.create(
+ contact=contact,
+ connection=self.integration,
+ external_user_id="user-existing",
+ )
+ conversation = Conversation.objects.create(
+ organization=self.organization,
+ channel=self.channel,
+ connection=self.integration,
+ contact=contact,
+ external_chat_id="chat-existing",
+ control_mode=ControlMode.AI,
+ expected_responder=ExpectedResponder.AI,
+ )
+
+ ai_turn, send = self._ingest_without_ai(
+ InboundMessage(
+ external_id="operator-2",
+ user_id="user-existing",
+ chat_id="chat-existing",
+ text="Вы здесь?",
+ display_name="Клиент",
+ )
+ )
+
+ conversation.refresh_from_db()
+ self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
+ self.assertEqual(
+ conversation.expected_responder, ExpectedResponder.OPERATOR
+ )
+ self.assertEqual(conversation.messages.count(), 1)
+ ai_turn.assert_not_called()
+ send.assert_not_called()
+
+
+class ClosedConversationActionTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(
+ email="owner@edevs.tech", password="temporary-password"
+ )
+ organization = Organization.objects.get(slug="edevs")
+ channel = Channel.objects.create(
+ organization=organization,
+ code="closed-actions",
+ name="Закрытые диалоги",
+ )
+ integration = _connection(channel)
+ contact = Contact.objects.create(organization=organization, name="Иван")
+ owner = HumanUser.objects.get(email="owner@edevs.tech")
+ self.conversation = Conversation.objects.create(
+ organization=organization,
+ channel=channel,
+ connection=integration,
+ contact=contact,
+ lifecycle=LifecycleState.CLOSED,
+ control_mode=ControlMode.HUMAN,
+ assigned_operator=owner,
+ expected_responder=ExpectedResponder.NOBODY,
+ )
+ self.client = APIClient()
+ self.client.login(
+ username="owner@edevs.tech", password="temporary-password"
+ )
+
+ def test_closed_dialog_rejects_operational_actions(self) -> None:
+ base = f"/api/v1/conversations/{self.conversation.id}"
+ self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409)
+ self.assertEqual(self.client.post(f"{base}/release/").status_code, 409)
+ self.assertEqual(
+ self.client.post(f"{base}/return-queue/").status_code, 409
+ )
+ self.assertEqual(
+ self.client.post(
+ f"{base}/messages/",
+ data=json.dumps({"text": "Поздний ответ"}),
+ content_type="application/json",
+ ).status_code,
+ 409,
+ )
+ self.assertEqual(self.client.post(f"{base}/close/").status_code, 409)
+ self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409)
+
+ def test_open_dialog_can_be_marked_as_spam(self) -> None:
+ self.conversation.lifecycle = LifecycleState.OPEN
+ self.conversation.expected_responder = ExpectedResponder.OPERATOR
+ self.conversation.save(
+ update_fields=["lifecycle", "expected_responder"]
+ )
+
+ response = self.client.post(
+ f"/api/v1/conversations/{self.conversation.id}/spam/"
+ )
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()["conversation"]
+ self.assertEqual(payload["lifecycle"], LifecycleState.SPAM)
+ self.assertEqual(payload["controlMode"], ControlMode.PAUSED)
+ self.assertIsNone(payload["assignedOperator"])
+ self.assertEqual(
+ payload["expectedResponder"], ExpectedResponder.NOBODY
+ )
diff --git a/apps/backend/hub_platform/conversations/test_voice_messages.py b/apps/backend/chatballs/conversations/test_voice_messages.py
similarity index 91%
rename from apps/backend/hub_platform/conversations/test_voice_messages.py
rename to apps/backend/chatballs/conversations/test_voice_messages.py
index 9254a61..92784d9 100644
--- a/apps/backend/hub_platform/conversations/test_voice_messages.py
+++ b/apps/backend/chatballs/conversations/test_voice_messages.py
@@ -3,11 +3,11 @@ from unittest import mock
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.ingest import ingest_inbound
-from hub_platform.conversations.models import (
+from chatballs.channels.models import Channel
+from chatballs.conversations.ingest import ingest_inbound
+from chatballs.conversations.models import (
Contact,
ControlMode,
Conversation,
@@ -16,16 +16,16 @@ from hub_platform.conversations.models import (
MessageKind,
TranscriptStatus,
)
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.identity.models import (
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.integrations.models import (
+from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
@@ -69,7 +69,7 @@ class VoiceTestCase(TestCase):
class VoiceIngestTests(VoiceTestCase):
def test_inbound_voice_is_stored_and_queued_for_operator(self) -> None:
with mock.patch(
- "hub_platform.conversations.ingest.transports.download_voice",
+ "chatballs.conversations.ingest.transports.download_voice",
return_value=(b"OGGDATA", "audio/ogg"),
), tenant_atomic(self.organization.id):
# Продовый поллер выполняет ingest в tenant-контексте БД —
@@ -86,7 +86,7 @@ class VoiceIngestTests(VoiceTestCase):
def test_download_failure_keeps_placeholder_message(self) -> None:
with mock.patch(
- "hub_platform.conversations.ingest.transports.download_voice",
+ "chatballs.conversations.ingest.transports.download_voice",
side_effect=ValueError("boom"),
), tenant_atomic(self.organization.id):
ingest_inbound(self.integration, self._voice_inbound())
@@ -156,7 +156,7 @@ class VoiceApiTests(VoiceTestCase):
# Повтор идемпотентен: провайдер не вызывается заново.
with mock.patch(
- "hub_platform.ai.provider.local.LocalProvider.transcribe"
+ "chatballs.ai.provider.local.LocalProvider.transcribe"
) as transcribe:
repeat = self.client.post(
f"/api/v1/conversations/messages/{message.id}/transcribe/"
@@ -175,7 +175,7 @@ class VoiceApiTests(VoiceTestCase):
)
with mock.patch(
- "hub_platform.conversations.transports.send_voice", return_value=True
+ "chatballs.conversations.transports.send_voice", return_value=True
) as send:
response = self.client.post(
f"/api/v1/conversations/{conversation.id}/voice/",
@@ -195,8 +195,8 @@ class VoiceApiTests(VoiceTestCase):
self.assertTrue(sent.audio)
def test_max_send_voice_uploads_then_sends_attachment(self) -> None:
- from hub_platform.conversations import transports
- from hub_platform.conversations.transports import max as max_transport
+ from chatballs.conversations import transports
+ from chatballs.conversations.transports import max as max_transport
integration = Integration.objects.create(
organization=self.organization,
diff --git a/apps/backend/hub_platform/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py
similarity index 91%
rename from apps/backend/hub_platform/conversations/tests.py
rename to apps/backend/chatballs/conversations/tests.py
index 3012188..83fb8ec 100644
--- a/apps/backend/hub_platform/conversations/tests.py
+++ b/apps/backend/chatballs/conversations/tests.py
@@ -3,13 +3,13 @@ import json
from unittest import mock
from django.test import TestCase, override_settings
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.ai.limits import LimitExceeded
-from hub_platform.ai.provider.base import ProviderError
-from hub_platform.ai.models import AIAgent, AIAgentStatus
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.models import (
+from chatballs.ai.limits import LimitExceeded
+from chatballs.ai.provider.base import ProviderError
+from chatballs.ai.models import AIAgent, AIAgentStatus
+from chatballs.channels.models import Channel
+from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
@@ -19,18 +19,18 @@ from hub_platform.conversations.models import (
MessageAuthor,
MessageKind,
)
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.conversations.transports import max as max_transport
-from hub_platform.conversations.transports import telegram as telegram_transport
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import (
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.conversations.transports import max as max_transport
+from chatballs.conversations.transports import telegram as telegram_transport
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
-from hub_platform.notifications.models import Notification, NotificationAudience, NotificationType
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
+from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
def _messenger_connection(channel):
@@ -65,14 +65,14 @@ class IngestLimitHandlingTests(TestCase):
)
def test_limit_exceeded_hands_off_to_operator(self) -> None:
- from hub_platform.conversations.ingest import ingest_inbound
+ from chatballs.conversations.ingest import ingest_inbound
with (
mock.patch(
- "hub_platform.conversations.ingest.run_channel_turn",
+ "chatballs.conversations.ingest.run_channel_turn",
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
),
- mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True) as send,
+ mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
):
ingest_inbound(self.integration, self.inbound)
@@ -189,14 +189,14 @@ class ContactShareIngestTests(TestCase):
self.integration = _messenger_connection(self.channel)
def test_username_saved_on_identity(self) -> None:
- from hub_platform.conversations.ingest import ingest_inbound
+ from chatballs.conversations.ingest import ingest_inbound
inbound = InboundMessage(
external_id="ext-1", user_id="u1", chat_id="c1", text="Привет", display_name="Иван", username="ivan"
)
with (
- mock.patch("hub_platform.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Здравствуйте!")),
- mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True),
+ mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Здравствуйте!")),
+ mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
):
ingest_inbound(self.integration, inbound)
@@ -204,14 +204,14 @@ class ContactShareIngestTests(TestCase):
self.assertEqual(identity.username, "ivan")
def test_contact_share_saves_phone_without_ai_turn(self) -> None:
- from hub_platform.conversations.ingest import ingest_inbound
+ from chatballs.conversations.ingest import ingest_inbound
inbound = InboundMessage(
external_id="ext-2", user_id="u1", chat_id="c1", text="", display_name="Иван", username="ivan", phone="+79991234567"
)
with (
- mock.patch("hub_platform.conversations.ingest.run_channel_turn") as ai_turn,
- mock.patch("hub_platform.conversations.ingest.transports.send_contact_ack", return_value=True) as ack,
+ mock.patch("chatballs.conversations.ingest.run_channel_turn") as ai_turn,
+ mock.patch("chatballs.conversations.ingest.transports.send_contact_ack", return_value=True) as ack,
):
ingest_inbound(self.integration, inbound)
@@ -247,7 +247,7 @@ class RequestContactApiTests(TestCase):
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_request_contact_creates_message_and_sends_button(self) -> None:
- with mock.patch("hub_platform.conversations.services.transports.send_contact_request", return_value=True) as send:
+ with mock.patch("chatballs.conversations.services.transports.send_contact_request", return_value=True) as send:
response = self.client.post(f"/api/v1/conversations/{self.conversation.id}/request-contact/")
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["message"]["kind"], MessageKind.CONTACT_REQUEST)
@@ -355,7 +355,7 @@ class WebchatContactTests(TestCase):
model="openai/gpt-4o-mini",
status=AIAgentStatus.ACTIVE,
)
- from hub_platform.webchat.testing import create_web_widget
+ from chatballs.webchat.testing import create_web_widget
self.widget = create_web_widget(self.channel, name="web-widget")
self.client = APIClient()
@@ -396,7 +396,7 @@ class WebchatContactTests(TestCase):
response = self._post_contact("12345")
self.assertEqual(response.status_code, 400)
- @override_settings(CUS_AI_PROVIDER="")
+ @override_settings(CHATBALLS_AI_PROVIDER="")
def test_missing_provider_integration_hands_off_without_500_and_notifies_management(
self,
) -> None:
@@ -442,7 +442,7 @@ class WebchatContactTests(TestCase):
def test_provider_error_hands_off_without_500(self) -> None:
with mock.patch(
- "hub_platform.conversations.ingest.run_channel_turn",
+ "chatballs.conversations.ingest.run_channel_turn",
side_effect=ProviderError("AI недоступен"),
):
response = self._post_message("Здравствуйте")
diff --git a/apps/backend/hub_platform/conversations/transports/__init__.py b/apps/backend/chatballs/conversations/transports/__init__.py
similarity index 94%
rename from apps/backend/hub_platform/conversations/transports/__init__.py
rename to apps/backend/chatballs/conversations/transports/__init__.py
index 5dc52ee..ff2b8b4 100644
--- a/apps/backend/hub_platform/conversations/transports/__init__.py
+++ b/apps/backend/chatballs/conversations/transports/__init__.py
@@ -1,7 +1,7 @@
-from hub_platform.conversations.transports import email as _email
-from hub_platform.conversations.transports import max as _max
-from hub_platform.conversations.transports import telegram as _telegram
-from hub_platform.integrations.models import IntegrationProvider
+from chatballs.conversations.transports import email as _email
+from chatballs.conversations.transports import max as _max
+from chatballs.conversations.transports import telegram as _telegram
+from chatballs.integrations.models import IntegrationProvider
_POLL = {
IntegrationProvider.MAX: _max.poll_updates,
diff --git a/apps/backend/hub_platform/conversations/transports/base.py b/apps/backend/chatballs/conversations/transports/base.py
similarity index 96%
rename from apps/backend/hub_platform/conversations/transports/base.py
rename to apps/backend/chatballs/conversations/transports/base.py
index 2220b01..8b6949e 100644
--- a/apps/backend/hub_platform/conversations/transports/base.py
+++ b/apps/backend/chatballs/conversations/transports/base.py
@@ -10,7 +10,7 @@ from dataclasses import dataclass
from django.conf import settings
-from hub_platform.integrations.proxy import build_opener
+from chatballs.integrations.proxy import build_opener
@dataclass(frozen=True)
@@ -46,7 +46,7 @@ class InboundMessage:
def request_json(url: str, *, headers: dict | None = None, method: str = "GET", body: dict | None = None, proxy_url: str = "") -> dict:
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
- with build_opener(proxy_url).open(request, timeout=settings.CUS_AI_REQUEST_TIMEOUT) as response:
+ with build_opener(proxy_url).open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else {}
@@ -62,7 +62,7 @@ def first(d: dict, *keys, default=None):
def download_bytes(url: str, *, proxy_url: str = "", max_bytes: int = 20 * 1024 * 1024) -> bytes:
"""Скачивание файла провайдера (голосовые ~десятки КБ; жёсткий предел 20МБ)."""
request = urllib.request.Request(url)
- with build_opener(proxy_url).open(request, timeout=settings.CUS_AI_REQUEST_TIMEOUT) as response:
+ with build_opener(proxy_url).open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
data = response.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("Файл больше допустимого размера")
@@ -113,6 +113,6 @@ def request_json_multipart(
request = urllib.request.Request(
url, data=body, headers={**(headers or {}), "Content-Type": body_type}, method="POST"
)
- with build_opener(proxy_url).open(request, timeout=settings.CUS_AI_REQUEST_TIMEOUT) as response:
+ with build_opener(proxy_url).open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else {}
diff --git a/apps/backend/chatballs/conversations/transports/email.py b/apps/backend/chatballs/conversations/transports/email.py
new file mode 100644
index 0000000..504f7bb
--- /dev/null
+++ b/apps/backend/chatballs/conversations/transports/email.py
@@ -0,0 +1,195 @@
+"""Email (IMAP/SMTP) transport (ADR-HUB-0035, SPEC-HUB-0025 §3.3–3.4).
+
+Polling IMAP with a UID cursor in poll_marker («uidvalidity:last_uid»), replies
+via SMTP into the same thread (Re:/In-Reply-To/References from the dialog's
+transport_meta). One mailbox password (app password) serves both sides.
+"""
+
+from __future__ import annotations
+
+import html
+import imaplib
+import logging
+import re
+import smtplib
+from email import message_from_bytes, policy
+from email.message import EmailMessage
+from email.utils import parseaddr
+
+from django.conf import settings
+
+from chatballs.conversations.html_sanitizer import sanitize_email_html
+from chatballs.conversations.transports.base import InboundMessage
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_IMAP_PORT = 993
+DEFAULT_SMTP_PORT = 465
+
+_TAG = re.compile(r"<[^>]+>")
+_BLANK_LINES = re.compile(r"\n{3,}")
+
+
+def _address(integration) -> str:
+ return str(integration.config.get("email", "")).strip().lower()
+
+
+def _imap_connect(integration) -> imaplib.IMAP4:
+ host = str(integration.config.get("imap_host", "")).strip()
+ port = int(integration.config.get("imap_port") or DEFAULT_IMAP_PORT)
+ timeout = settings.CHATBALLS_AI_REQUEST_TIMEOUT
+ use_ssl = integration.config.get("imap_ssl", True)
+ client = imaplib.IMAP4_SSL(host, port, timeout=timeout) if use_ssl else imaplib.IMAP4(host, port, timeout=timeout)
+ client.login(_address(integration), integration.secret)
+ return client
+
+
+def _html_to_text(markup: str) -> str:
+ text = _TAG.sub(" ", re.sub(r"(?i)
|
|", "\n", markup))
+ return _BLANK_LINES.sub("\n\n", html.unescape(text)).strip()
+
+
+def _body_text(message) -> str:
+ plain = message.get_body(preferencelist=("plain",))
+ if plain is not None:
+ return str(plain.get_content()).strip()
+ rich = message.get_body(preferencelist=("html",))
+ return _html_to_text(str(rich.get_content())) if rich is not None else ""
+
+
+def _body_html(message) -> str:
+ rich = message.get_body(preferencelist=("html",))
+ if rich is None:
+ return ""
+ return sanitize_email_html(str(rich.get_content()))
+
+
+def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundMessage | None, str]:
+ """Parse one RFC822 message → (InboundMessage | None, raw Message-ID)."""
+ display_name, address = parseaddr(str(message.get("From", "")))
+ address = address.strip().lower()
+ # Собственные письма ящика (копии отправленного) диалогом не являются.
+ if not address or address == own_address:
+ return None, ""
+ message_id = str(message.get("Message-ID", "")).strip()
+ text = _body_text(message)
+ content_html = _body_html(message)
+ attachments = sum(1 for _ in message.iter_attachments())
+ if attachments:
+ # Вложения в первой итерации не принимаются (ADR-HUB-0035).
+ note = f"[Вложения не поддерживаются: {attachments} файл(ов)]"
+ text = f"{text}\n\n{note}".strip()
+ if not text:
+ return None, message_id
+ inbound = InboundMessage(
+ external_id=message_id or fallback_id,
+ user_id=address,
+ chat_id=address,
+ text=text,
+ content_html=content_html,
+ display_name=str(display_name).strip() or address,
+ thread_meta={"subject": str(message.get("Subject", "")).strip(), "last_message_id": message_id},
+ )
+ return inbound, message_id
+
+
+def _status_value(client: imaplib.IMAP4, key: str) -> int:
+ _, data = client.status("INBOX", f"({key})")
+ match = re.search(rf"{key}\s+(\d+)", (data[0] or b"").decode("ascii", "replace"))
+ return int(match.group(1)) if match else 0
+
+
+def _parse_marker(marker: str) -> tuple[int, int]:
+ try:
+ validity, last_uid = marker.split(":", 1)
+ return int(validity), int(last_uid)
+ except (ValueError, AttributeError):
+ return 0, 0
+
+
+def poll_updates(integration) -> tuple[list[InboundMessage], str]:
+ if not integration.secret or not integration.config.get("imap_host"):
+ return [], integration.poll_marker
+ try:
+ client = _imap_connect(integration)
+ except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
+ logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
+ return [], integration.poll_marker
+ try:
+ client.select("INBOX", readonly=True)
+ validity = _status_value(client, "UIDVALIDITY")
+ next_uid = _status_value(client, "UIDNEXT")
+ known_validity, last_uid = _parse_marker(integration.poll_marker)
+ if validity != known_validity:
+ # Первый запуск или смена UIDVALIDITY: курсор — на текущий конец
+ # ящика, история не импортируется (SPEC-HUB-0025 §3.3).
+ return [], f"{validity}:{max(next_uid - 1, 0)}"
+ _, found = client.uid("SEARCH", None, f"UID {last_uid + 1}:*")
+ # IMAP-диапазон N:* всегда включает старшее письмо — отсекаем уже виденные.
+ uids = sorted(int(u) for u in (found[0] or b"").split() if int(u) > last_uid)
+ messages: list[InboundMessage] = []
+ for uid in uids:
+ _, fetched = client.uid("FETCH", str(uid), "(RFC822)")
+ raw = next((part[1] for part in fetched if isinstance(part, tuple)), None)
+ if raw is None:
+ continue
+ parsed = message_from_bytes(raw, policy=policy.default)
+ inbound, _ = _normalize(parsed, own_address=_address(integration), fallback_id=f"{validity}:{uid}")
+ if inbound is not None:
+ messages.append(inbound)
+ new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
+ return messages, new_marker
+ except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
+ logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
+ return [], integration.poll_marker
+ finally:
+ try:
+ client.logout()
+ except (imaplib.IMAP4.error, OSError):
+ pass
+
+
+def _thread_meta(integration, address: str) -> dict:
+ from chatballs.conversations.models import Conversation
+
+ conversation = (
+ Conversation.objects.filter(connection=integration, external_chat_id=address)
+ .order_by("-last_activity_at")
+ .first()
+ )
+ return dict(conversation.transport_meta or {}) if conversation else {}
+
+
+def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
+ target = (chat_id or user_id).strip().lower()
+ host = str(integration.config.get("smtp_host", "")).strip()
+ if not target or not host or not integration.secret:
+ return False
+ port = int(integration.config.get("smtp_port") or DEFAULT_SMTP_PORT)
+ meta = _thread_meta(integration, target)
+
+ outgoing = EmailMessage()
+ outgoing["From"] = _address(integration)
+ outgoing["To"] = target
+ subject = str(meta.get("subject", "")).strip()
+ outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else (subject or "Re: Ваше обращение")
+ last_message_id = str(meta.get("last_message_id", "")).strip()
+ if last_message_id:
+ outgoing["In-Reply-To"] = last_message_id
+ outgoing["References"] = last_message_id
+ outgoing.set_content(text)
+
+ timeout = settings.CHATBALLS_AI_REQUEST_TIMEOUT
+ try:
+ if integration.config.get("smtp_ssl", True):
+ client = smtplib.SMTP_SSL(host, port, timeout=timeout)
+ else:
+ client = smtplib.SMTP(host, port, timeout=timeout)
+ client.starttls()
+ with client:
+ client.login(_address(integration), integration.secret)
+ client.send_message(outgoing)
+ return True
+ except (smtplib.SMTPException, OSError, TimeoutError) as error:
+ logger.warning("Email SMTP send failed for integration %s: %s", integration.id, error)
+ return False
diff --git a/apps/backend/hub_platform/conversations/transports/max.py b/apps/backend/chatballs/conversations/transports/max.py
similarity index 97%
rename from apps/backend/hub_platform/conversations/transports/max.py
rename to apps/backend/chatballs/conversations/transports/max.py
index c71aa3a..32e5c11 100644
--- a/apps/backend/hub_platform/conversations/transports/max.py
+++ b/apps/backend/chatballs/conversations/transports/max.py
@@ -17,8 +17,8 @@ import urllib.parse
from django.conf import settings
-from hub_platform.conversations.transports.base import InboundMessage, download_bytes, first, request_json, request_json_multipart
-from hub_platform.integrations.checks import DEFAULT_MAX_BASE_URL
+from chatballs.conversations.transports.base import InboundMessage, download_bytes, first, request_json, request_json_multipart
+from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
logger = logging.getLogger(__name__)
@@ -120,7 +120,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
token = integration.secret
if not token:
return [], integration.poll_marker
- params = {"timeout": settings.CUS_MESSENGER_POLL_TIMEOUT_SECONDS, "limit": 100}
+ params = {"timeout": settings.CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS, "limit": 100}
if integration.poll_marker:
params["marker"] = integration.poll_marker
url = f"{_base(integration)}/updates?{urllib.parse.urlencode(params)}"
diff --git a/apps/backend/hub_platform/conversations/transports/telegram.py b/apps/backend/chatballs/conversations/transports/telegram.py
similarity index 96%
rename from apps/backend/hub_platform/conversations/transports/telegram.py
rename to apps/backend/chatballs/conversations/transports/telegram.py
index 814a244..c39d2a6 100644
--- a/apps/backend/hub_platform/conversations/transports/telegram.py
+++ b/apps/backend/chatballs/conversations/transports/telegram.py
@@ -14,8 +14,8 @@ import urllib.error
from django.conf import settings
-from hub_platform.conversations.transports.base import InboundMessage, download_bytes, request_json, request_json_multipart
-from hub_platform.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
+from chatballs.conversations.transports.base import InboundMessage, download_bytes, request_json, request_json_multipart
+from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
if not token:
return [], integration.poll_marker
offset = integration.poll_marker or ""
- url = f"{_base(integration)}/bot{token}/getUpdates?timeout={settings.CUS_MESSENGER_POLL_TIMEOUT_SECONDS}&limit=100"
+ url = f"{_base(integration)}/bot{token}/getUpdates?timeout={settings.CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS}&limit=100"
if offset:
url += f"&offset={offset}"
try:
diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/chatballs/conversations/urls.py
similarity index 97%
rename from apps/backend/hub_platform/conversations/urls.py
rename to apps/backend/chatballs/conversations/urls.py
index 93437d4..2990fdb 100644
--- a/apps/backend/hub_platform/conversations/urls.py
+++ b/apps/backend/chatballs/conversations/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.conversations import chat_extras_views, reporting_views, views, voice_views
+from chatballs.conversations import chat_extras_views, reporting_views, views, voice_views
urlpatterns = [
path("", views.ConversationListView.as_view(), name="conversation-list"),
diff --git a/apps/backend/chatballs/conversations/view_base.py b/apps/backend/chatballs/conversations/view_base.py
new file mode 100644
index 0000000..ffa6346
--- /dev/null
+++ b/apps/backend/chatballs/conversations/view_base.py
@@ -0,0 +1,43 @@
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from chatballs.api.permissions import HasCapability
+from chatballs.conversations.models import Conversation
+from chatballs.conversations.selectors import conversation_for_context
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.policy import require_capability
+
+
+class ConversationViewBase(APIView):
+ permission_classes = [HasCapability]
+ required_capability = "conversations.view"
+
+ def _org(self, request: Request):
+ return request.tenant_context.organization
+
+ def _conversation(
+ self,
+ request: Request,
+ conversation_id: int,
+ capability: str = "conversations.view",
+ ) -> Conversation:
+ conversation = conversation_for_context(
+ context=request.tenant_context, conversation_id=conversation_id
+ )
+ if not require_capability(
+ request.tenant_context.membership, capability, conversation
+ ):
+ raise Conversation.DoesNotExist
+ return conversation
+
+ def _audit(
+ self, request: Request, action: str, conversation: Conversation
+ ) -> None:
+ record_audit_event(
+ action=f"conversations.{action}",
+ actor=request.user,
+ organization=self._org(request),
+ object_type="Conversation",
+ object_id=str(conversation.id),
+ request=request,
+ )
diff --git a/apps/backend/hub_platform/conversations/views.py b/apps/backend/chatballs/conversations/views.py
similarity index 96%
rename from apps/backend/hub_platform/conversations/views.py
rename to apps/backend/chatballs/conversations/views.py
index 8531d5e..705a8c2 100644
--- a/apps/backend/hub_platform/conversations/views.py
+++ b/apps/backend/chatballs/conversations/views.py
@@ -2,16 +2,16 @@ from django.db import transaction
from rest_framework.request import Request
from rest_framework.response import Response
-from hub_platform.conversations.models import (
+from chatballs.conversations.models import (
ControlMode,
Conversation,
ConversationRead,
LifecycleState,
Message,
)
-from hub_platform.conversations.selectors import visible_conversations_for
-from hub_platform.conversations.serializers import conversation_payload, message_payload
-from hub_platform.conversations.services import (
+from chatballs.conversations.selectors import visible_conversations_for
+from chatballs.conversations.serializers import conversation_payload, message_payload
+from chatballs.conversations.services import (
ClaimError,
claim_conversation,
close_conversation,
@@ -21,10 +21,10 @@ from hub_platform.conversations.services import (
request_contact,
return_to_queue,
)
-from hub_platform.conversations.view_base import ConversationViewBase
-from hub_platform.identity.group_models import EmployeeGroup
-from hub_platform.identity.models import OrganizationMembership
-from hub_platform.identity.policy import ResourceScope, authorize, can_administer_access
+from chatballs.conversations.view_base import ConversationViewBase
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.identity.models import OrganizationMembership
+from chatballs.identity.policy import ResourceScope, authorize, can_administer_access
class ConversationListView(ConversationViewBase):
diff --git a/apps/backend/hub_platform/conversations/voice_views.py b/apps/backend/chatballs/conversations/voice_views.py
similarity index 93%
rename from apps/backend/hub_platform/conversations/voice_views.py
rename to apps/backend/chatballs/conversations/voice_views.py
index 6487dc9..8689824 100644
--- a/apps/backend/hub_platform/conversations/voice_views.py
+++ b/apps/backend/chatballs/conversations/voice_views.py
@@ -14,9 +14,9 @@ from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
-from hub_platform.ai.provider.base import ProviderError
-from hub_platform.conversations import transports
-from hub_platform.conversations.models import (
+from chatballs.ai.provider.base import ProviderError
+from chatballs.conversations import transports
+from chatballs.conversations.models import (
ControlMode,
Conversation,
LifecycleState,
@@ -25,9 +25,9 @@ from hub_platform.conversations.models import (
MessageKind,
TranscriptStatus,
)
-from hub_platform.conversations.selectors import conversation_for_context
-from hub_platform.conversations.serializers import message_payload
-from hub_platform.conversations.view_base import ConversationViewBase
+from chatballs.conversations.selectors import conversation_for_context
+from chatballs.conversations.serializers import message_payload
+from chatballs.conversations.view_base import ConversationViewBase
MAX_VOICE_BYTES = 10 * 1024 * 1024
ALLOWED_AUDIO_TYPES = ("audio/ogg", "audio/webm", "audio/mpeg", "audio/mp4", "audio/wav")
@@ -75,7 +75,7 @@ class MessageTranscribeView(ConversationViewBase):
from django.conf import settings
- from hub_platform.ai.provider.factory import get_provider
+ from chatballs.ai.provider.factory import get_provider
try:
provider = get_provider(channel=message.conversation.channel)
@@ -85,7 +85,7 @@ class MessageTranscribeView(ConversationViewBase):
audio=audio,
filename=message.audio.name.rsplit("/", 1)[-1],
content_type=message.audio_content_type or "audio/ogg",
- model=settings.CUS_AI_TRANSCRIPTION_MODEL,
+ model=settings.CHATBALLS_AI_TRANSCRIPTION_MODEL,
)
except ProviderError as error:
message.transcript_status = TranscriptStatus.FAILED
diff --git a/apps/backend/hub_platform/calls/tests/__init__.py b/apps/backend/chatballs/events/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/calls/tests/__init__.py
rename to apps/backend/chatballs/events/__init__.py
diff --git a/apps/backend/hub_platform/events/admin.py b/apps/backend/chatballs/events/admin.py
similarity index 94%
rename from apps/backend/hub_platform/events/admin.py
rename to apps/backend/chatballs/events/admin.py
index 8d4dfa5..2850c69 100644
--- a/apps/backend/hub_platform/events/admin.py
+++ b/apps/backend/chatballs/events/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from hub_platform.events.models import InboxEvent, OutboxEvent
+from chatballs.events.models import InboxEvent, OutboxEvent
@admin.register(OutboxEvent)
diff --git a/apps/backend/hub_platform/events/apps.py b/apps/backend/chatballs/events/apps.py
similarity index 82%
rename from apps/backend/hub_platform/events/apps.py
rename to apps/backend/chatballs/events/apps.py
index 46835f3..b265aed 100644
--- a/apps/backend/hub_platform/events/apps.py
+++ b/apps/backend/chatballs/events/apps.py
@@ -3,5 +3,5 @@ from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.events"
+ name = "chatballs.events"
verbose_name = "Domain events"
diff --git a/apps/backend/hub_platform/events/context.py b/apps/backend/chatballs/events/context.py
similarity index 100%
rename from apps/backend/hub_platform/events/context.py
rename to apps/backend/chatballs/events/context.py
diff --git a/apps/backend/hub_platform/events/handlers.py b/apps/backend/chatballs/events/handlers.py
similarity index 77%
rename from apps/backend/hub_platform/events/handlers.py
rename to apps/backend/chatballs/events/handlers.py
index 8dd82bd..c5780d5 100644
--- a/apps/backend/hub_platform/events/handlers.py
+++ b/apps/backend/chatballs/events/handlers.py
@@ -1,10 +1,10 @@
import logging
from typing import Callable
-from hub_platform.events.models import OutboxEvent
-from hub_platform.events.services import tenant_context_for_event
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
+from chatballs.events.models import OutboxEvent
+from chatballs.events.services import tenant_context_for_event
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/events/logging.py b/apps/backend/chatballs/events/logging.py
similarity index 76%
rename from apps/backend/hub_platform/events/logging.py
rename to apps/backend/chatballs/events/logging.py
index 5b33549..45d8a87 100644
--- a/apps/backend/hub_platform/events/logging.py
+++ b/apps/backend/chatballs/events/logging.py
@@ -1,6 +1,6 @@
import logging
-from hub_platform.events.context import get_correlation_id
+from chatballs.events.context import get_correlation_id
class CorrelationIdLogFilter(logging.Filter):
diff --git a/apps/backend/hub_platform/events/__init__.py b/apps/backend/chatballs/events/management/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/events/__init__.py
rename to apps/backend/chatballs/events/management/__init__.py
diff --git a/apps/backend/hub_platform/events/management/__init__.py b/apps/backend/chatballs/events/management/commands/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/events/management/__init__.py
rename to apps/backend/chatballs/events/management/commands/__init__.py
diff --git a/apps/backend/hub_platform/events/management/commands/emit_test_event.py b/apps/backend/chatballs/events/management/commands/emit_test_event.py
similarity index 88%
rename from apps/backend/hub_platform/events/management/commands/emit_test_event.py
rename to apps/backend/chatballs/events/management/commands/emit_test_event.py
index 549a972..3062283 100644
--- a/apps/backend/hub_platform/events/management/commands/emit_test_event.py
+++ b/apps/backend/chatballs/events/management/commands/emit_test_event.py
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
-from hub_platform.events.services import DomainEvent, enqueue_event
+from chatballs.events.services import DomainEvent, enqueue_event
class Command(BaseCommand):
diff --git a/apps/backend/hub_platform/events/management/commands/run_worker.py b/apps/backend/chatballs/events/management/commands/run_worker.py
similarity index 85%
rename from apps/backend/hub_platform/events/management/commands/run_worker.py
rename to apps/backend/chatballs/events/management/commands/run_worker.py
index 321e1a6..5e9249d 100644
--- a/apps/backend/hub_platform/events/management/commands/run_worker.py
+++ b/apps/backend/chatballs/events/management/commands/run_worker.py
@@ -4,16 +4,16 @@ import time
from django.core.management.base import BaseCommand
from django.utils import timezone
-from hub_platform.calls.maintenance import expire_stale_calls
-from hub_platform.conversations.maintenance import close_stale_conversations
-from hub_platform.conversations.poller import poll_all_messengers
-from hub_platform.events.handlers import dispatch
-from hub_platform.events.models import OutboxStatus
-from hub_platform.events.services import claim_next_outbox_event, mark_retry
-from hub_platform.identity.models import Organization
-from hub_platform.notifications.binding import poll_notifier_bots
-from hub_platform.tenancy.context import TenantActorKind, TenantContext
-from hub_platform.tenancy.database import tenant_atomic
+from chatballs.calls.maintenance import expire_stale_calls
+from chatballs.conversations.maintenance import close_stale_conversations
+from chatballs.conversations.poller import poll_all_messengers
+from chatballs.events.handlers import dispatch
+from chatballs.events.models import OutboxStatus
+from chatballs.events.services import claim_next_outbox_event, mark_retry
+from chatballs.identity.models import Organization
+from chatballs.notifications.binding import poll_notifier_bots
+from chatballs.tenancy.context import TenantActorKind, TenantContext
+from chatballs.tenancy.database import tenant_atomic
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/events/middleware.py b/apps/backend/chatballs/events/middleware.py
similarity index 90%
rename from apps/backend/hub_platform/events/middleware.py
rename to apps/backend/chatballs/events/middleware.py
index 3873d78..f8013d0 100644
--- a/apps/backend/hub_platform/events/middleware.py
+++ b/apps/backend/chatballs/events/middleware.py
@@ -2,7 +2,7 @@ from collections.abc import Callable
from django.http import HttpRequest, HttpResponse
-from hub_platform.events.context import ensure_correlation_id
+from chatballs.events.context import ensure_correlation_id
class CorrelationIdMiddleware:
diff --git a/apps/backend/hub_platform/events/migrations/0001_initial.py b/apps/backend/chatballs/events/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/events/migrations/0001_initial.py
rename to apps/backend/chatballs/events/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/events/migrations/0002_rename_events_outb_status_881ef5_idx_events_outb_status_9dabce_idx_and_more.py b/apps/backend/chatballs/events/migrations/0002_rename_events_outb_status_881ef5_idx_events_outb_status_9dabce_idx_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/events/migrations/0002_rename_events_outb_status_881ef5_idx_events_outb_status_9dabce_idx_and_more.py
rename to apps/backend/chatballs/events/migrations/0002_rename_events_outb_status_881ef5_idx_events_outb_status_9dabce_idx_and_more.py
diff --git a/apps/backend/hub_platform/events/migrations/0003_outbox_tenant_context.py b/apps/backend/chatballs/events/migrations/0003_outbox_tenant_context.py
similarity index 100%
rename from apps/backend/hub_platform/events/migrations/0003_outbox_tenant_context.py
rename to apps/backend/chatballs/events/migrations/0003_outbox_tenant_context.py
diff --git a/apps/backend/hub_platform/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py b/apps/backend/chatballs/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py
rename to apps/backend/chatballs/events/migrations/0004_inboxevent_organization_inboxevent_ownership_and_more.py
diff --git a/apps/backend/hub_platform/events/management/commands/__init__.py b/apps/backend/chatballs/events/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/events/management/commands/__init__.py
rename to apps/backend/chatballs/events/migrations/__init__.py
diff --git a/apps/backend/hub_platform/events/models.py b/apps/backend/chatballs/events/models.py
similarity index 100%
rename from apps/backend/hub_platform/events/models.py
rename to apps/backend/chatballs/events/models.py
diff --git a/apps/backend/hub_platform/events/services.py b/apps/backend/chatballs/events/services.py
similarity index 92%
rename from apps/backend/hub_platform/events/services.py
rename to apps/backend/chatballs/events/services.py
index 1cfdfc1..2398189 100644
--- a/apps/backend/hub_platform/events/services.py
+++ b/apps/backend/chatballs/events/services.py
@@ -5,10 +5,10 @@ from typing import Any
from django.db import transaction
from django.utils import timezone
-from hub_platform.events.context import get_correlation_id
-from hub_platform.events.models import EventOwnership, OutboxEvent, OutboxStatus
-from hub_platform.identity.models import Organization, OrganizationMembership
-from hub_platform.tenancy.context import TenantActorKind, TenantContext
+from chatballs.events.context import get_correlation_id
+from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
+from chatballs.identity.models import Organization, OrganizationMembership
+from chatballs.tenancy.context import TenantActorKind, TenantContext
@dataclass(frozen=True)
@@ -53,7 +53,7 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
actor_kind=actor_kind,
correlation_id=event.correlation_id,
)
- from hub_platform.tenancy.database import tenant_atomic
+ from chatballs.tenancy.database import tenant_atomic
with tenant_atomic(resource_context):
membership = None
diff --git a/apps/backend/hub_platform/events/migrations/__init__.py b/apps/backend/chatballs/health/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/events/migrations/__init__.py
rename to apps/backend/chatballs/health/__init__.py
diff --git a/apps/backend/hub_platform/health/apps.py b/apps/backend/chatballs/health/apps.py
similarity index 82%
rename from apps/backend/hub_platform/health/apps.py
rename to apps/backend/chatballs/health/apps.py
index febd549..6f6e163 100644
--- a/apps/backend/hub_platform/health/apps.py
+++ b/apps/backend/chatballs/health/apps.py
@@ -3,5 +3,5 @@ from django.apps import AppConfig
class HealthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.health"
+ name = "chatballs.health"
verbose_name = "Health"
diff --git a/apps/backend/hub_platform/health/tests.py b/apps/backend/chatballs/health/tests.py
similarity index 100%
rename from apps/backend/hub_platform/health/tests.py
rename to apps/backend/chatballs/health/tests.py
diff --git a/apps/backend/hub_platform/health/urls.py b/apps/backend/chatballs/health/urls.py
similarity index 73%
rename from apps/backend/hub_platform/health/urls.py
rename to apps/backend/chatballs/health/urls.py
index fe23326..81c9754 100644
--- a/apps/backend/hub_platform/health/urls.py
+++ b/apps/backend/chatballs/health/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.health.views import live, ready
+from chatballs.health.views import live, ready
urlpatterns = [
path("live/", live, name="health-live"),
diff --git a/apps/backend/hub_platform/health/views.py b/apps/backend/chatballs/health/views.py
similarity index 94%
rename from apps/backend/hub_platform/health/views.py
rename to apps/backend/chatballs/health/views.py
index 98f2ff8..ee4f171 100644
--- a/apps/backend/hub_platform/health/views.py
+++ b/apps/backend/chatballs/health/views.py
@@ -5,7 +5,7 @@ from django.http import JsonResponse
def live(request):
- surface = settings.CUS_RUNTIME_SURFACE
+ surface = settings.CHATBALLS_RUNTIME_SURFACE
return JsonResponse(
{"status": "ok", "service": f"chatballs-{surface}", "surface": surface}
)
diff --git a/apps/backend/hub_platform/health/__init__.py b/apps/backend/chatballs/http/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/health/__init__.py
rename to apps/backend/chatballs/http/__init__.py
diff --git a/apps/backend/hub_platform/http/middleware.py b/apps/backend/chatballs/http/middleware.py
similarity index 96%
rename from apps/backend/hub_platform/http/middleware.py
rename to apps/backend/chatballs/http/middleware.py
index 68b50c2..b65e8f2 100644
--- a/apps/backend/hub_platform/http/middleware.py
+++ b/apps/backend/chatballs/http/middleware.py
@@ -35,7 +35,7 @@ class ContentSecurityPolicyMiddleware:
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
- policy = settings.CUS_CONTENT_SECURITY_POLICY
+ policy = settings.CHATBALLS_CONTENT_SECURITY_POLICY
if policy and not response.has_header("Content-Security-Policy"):
response["Content-Security-Policy"] = policy
return response
diff --git a/apps/backend/hub_platform/http/tests.py b/apps/backend/chatballs/http/tests.py
similarity index 91%
rename from apps/backend/hub_platform/http/tests.py
rename to apps/backend/chatballs/http/tests.py
index 268564a..04968e0 100644
--- a/apps/backend/hub_platform/http/tests.py
+++ b/apps/backend/chatballs/http/tests.py
@@ -22,13 +22,13 @@ class LocalCorsMiddlewareTests(TestCase):
class ContentSecurityPolicyMiddlewareTests(TestCase):
- @override_settings(CUS_CONTENT_SECURITY_POLICY="default-src 'none'")
+ @override_settings(CHATBALLS_CONTENT_SECURITY_POLICY="default-src 'none'")
def test_surface_policy_is_applied(self) -> None:
response = self.client.get("/api/v1/health/live/")
self.assertEqual(response["Content-Security-Policy"], "default-src 'none'")
- @override_settings(CUS_CONTENT_SECURITY_POLICY="")
+ @override_settings(CHATBALLS_CONTENT_SECURITY_POLICY="")
def test_empty_policy_does_not_add_header(self) -> None:
response = self.client.get("/api/v1/health/live/")
diff --git a/apps/backend/hub_platform/http/__init__.py b/apps/backend/chatballs/identity/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/http/__init__.py
rename to apps/backend/chatballs/identity/__init__.py
diff --git a/apps/backend/hub_platform/identity/admin.py b/apps/backend/chatballs/identity/admin.py
similarity index 98%
rename from apps/backend/hub_platform/identity/admin.py
rename to apps/backend/chatballs/identity/admin.py
index 39b6e5c..ff7aed5 100644
--- a/apps/backend/hub_platform/identity/admin.py
+++ b/apps/backend/chatballs/identity/admin.py
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
-from hub_platform.identity.models import (
+from chatballs.identity.models import (
AuditEvent,
EmployeeGroup,
HumanUser,
diff --git a/apps/backend/hub_platform/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py
similarity index 98%
rename from apps/backend/hub_platform/identity/administration_payloads.py
rename to apps/backend/chatballs/identity/administration_payloads.py
index a3098a7..86db159 100644
--- a/apps/backend/hub_platform/identity/administration_payloads.py
+++ b/apps/backend/chatballs/identity/administration_payloads.py
@@ -4,7 +4,7 @@ from zoneinfo import available_timezones
from django.urls import reverse
-from hub_platform.identity.models import AuditEvent, Organization
+from chatballs.identity.models import AuditEvent, Organization
AUDIT_ACTION_LABELS = {
diff --git a/apps/backend/hub_platform/identity/administration_services.py b/apps/backend/chatballs/identity/administration_services.py
similarity index 95%
rename from apps/backend/hub_platform/identity/administration_services.py
rename to apps/backend/chatballs/identity/administration_services.py
index 2898222..a6c586f 100644
--- a/apps/backend/hub_platform/identity/administration_services.py
+++ b/apps/backend/chatballs/identity/administration_services.py
@@ -9,10 +9,10 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.storage import adjust_storage_usage
-from hub_platform.tenancy.storage_quota import (
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.storage import adjust_storage_usage
+from chatballs.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
diff --git a/apps/backend/hub_platform/identity/administration_views.py b/apps/backend/chatballs/identity/administration_views.py
similarity index 93%
rename from apps/backend/hub_platform/identity/administration_views.py
rename to apps/backend/chatballs/identity/administration_views.py
index 5ed2dae..ad0279d 100644
--- a/apps/backend/hub_platform/identity/administration_views.py
+++ b/apps/backend/chatballs/identity/administration_views.py
@@ -8,20 +8,20 @@ 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.administration_payloads import (
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.administration_payloads import (
administration_timezones,
audit_event_payload,
organization_settings_payload,
)
-from hub_platform.identity.administration_services import (
+from chatballs.identity.administration_services import (
OrganizationSettingsInput,
delete_organization_logo,
replace_organization_logo,
update_organization_settings,
)
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.models import AuditEvent
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.models import AuditEvent
def _validation_response(error: ValidationError) -> Response:
@@ -159,9 +159,9 @@ class LaunchChecklistView(APIView):
required_capability = "settings.view"
def get(self, request: Request) -> Response:
- from hub_platform.channels.models import Channel
- from hub_platform.identity.models import OrganizationMembership
- from hub_platform.integrations.models import Integration, IntegrationKind
+ from chatballs.channels.models import Channel
+ from chatballs.identity.models import OrganizationMembership
+ from chatballs.integrations.models import Integration, IntegrationKind
organization_id = request.tenant_context.organization_id
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
diff --git a/apps/backend/hub_platform/identity/apps.py b/apps/backend/chatballs/identity/apps.py
similarity index 62%
rename from apps/backend/hub_platform/identity/apps.py
rename to apps/backend/chatballs/identity/apps.py
index 957d4ae..8848e53 100644
--- a/apps/backend/hub_platform/identity/apps.py
+++ b/apps/backend/chatballs/identity/apps.py
@@ -4,8 +4,8 @@ from django.apps import AppConfig
class IdentityConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "identity"
- name = "hub_platform.identity"
+ name = "chatballs.identity"
verbose_name = "Identity and platform core"
def ready(self) -> None:
- from hub_platform.identity import event_handlers # noqa: F401 (register outbox handlers)
+ from chatballs.identity import event_handlers # noqa: F401 (register outbox handlers)
diff --git a/apps/backend/hub_platform/identity/audit.py b/apps/backend/chatballs/identity/audit.py
similarity index 85%
rename from apps/backend/hub_platform/identity/audit.py
rename to apps/backend/chatballs/identity/audit.py
index 9b34f08..dbd7be4 100644
--- a/apps/backend/hub_platform/identity/audit.py
+++ b/apps/backend/chatballs/identity/audit.py
@@ -2,8 +2,8 @@ from typing import Any
from django.http import HttpRequest
-from hub_platform.events.context import get_correlation_id
-from hub_platform.identity.models import AuditEvent, AuditResult, HumanUser, Organization
+from chatballs.events.context import get_correlation_id
+from chatballs.identity.models import AuditEvent, AuditResult, HumanUser, Organization
def record_audit_event(
diff --git a/apps/backend/hub_platform/identity/auth/__init__.py b/apps/backend/chatballs/identity/auth/__init__.py
similarity index 68%
rename from apps/backend/hub_platform/identity/auth/__init__.py
rename to apps/backend/chatballs/identity/auth/__init__.py
index 057bcea..9ffb773 100644
--- a/apps/backend/hub_platform/identity/auth/__init__.py
+++ b/apps/backend/chatballs/identity/auth/__init__.py
@@ -1,10 +1,10 @@
-from hub_platform.identity.auth.invitations import InvitationAcceptView
-from hub_platform.identity.auth.password_reset import (
+from chatballs.identity.auth.invitations import InvitationAcceptView
+from chatballs.identity.auth.password_reset import (
PasswordResetConfirmView,
PasswordResetRequestView,
PasswordResetValidateView,
)
-from hub_platform.identity.auth.profile import (
+from chatballs.identity.auth.profile import (
ProfileAppearanceView,
ProfileAvatarView,
ChangeTemporaryPasswordView,
@@ -14,8 +14,8 @@ from hub_platform.identity.auth.profile import (
ProfileTotpStartView,
ProfileUpdateView,
)
-from hub_platform.identity.auth.sessions import LoginView, LogoutView, SessionView
-from hub_platform.identity.auth.totp import TotpConfirmView, TotpSetupView, TotpVerifyView
+from chatballs.identity.auth.sessions import LoginView, LogoutView, SessionView
+from chatballs.identity.auth.totp import TotpConfirmView, TotpSetupView, TotpVerifyView
__all__ = [
"SessionView",
diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/chatballs/identity/auth/common.py
similarity index 84%
rename from apps/backend/hub_platform/identity/auth/common.py
rename to apps/backend/chatballs/identity/auth/common.py
index 2b44da3..c0d0606 100644
--- a/apps/backend/hub_platform/identity/auth/common.py
+++ b/apps/backend/chatballs/identity/auth/common.py
@@ -1,12 +1,12 @@
from django.conf import settings
from rest_framework.request import Request
-from hub_platform.identity.avatars import own_avatar_url
-from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
-from hub_platform.identity.policy import get_effective_access
-from hub_platform.identity.sessions import revoke_user_sessions
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.ingress import membership_routes_for_user
+from chatballs.identity.avatars import own_avatar_url
+from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
+from chatballs.identity.policy import get_effective_access
+from chatballs.identity.sessions import revoke_user_sessions
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.ingress import membership_routes_for_user
def _user_payload(user: HumanUser) -> dict[str, object]:
@@ -56,7 +56,7 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"fullName": user.full_name,
"mustChangePassword": user.must_change_password,
"totpEnabled": user.totp_enabled,
- "deliveryMode": settings.CUS_DELIVERY_MODE,
+ "deliveryMode": settings.CHATBALLS_DELIVERY_MODE,
"uiTheme": user.ui_theme,
"uiAccent": user.ui_accent,
"avatarUrl": own_avatar_url(user),
diff --git a/apps/backend/hub_platform/identity/auth/invitations.py b/apps/backend/chatballs/identity/auth/invitations.py
similarity index 88%
rename from apps/backend/hub_platform/identity/auth/invitations.py
rename to apps/backend/chatballs/identity/auth/invitations.py
index 39b0ed6..fde7aba 100644
--- a/apps/backend/hub_platform/identity/auth/invitations.py
+++ b/apps/backend/chatballs/identity/auth/invitations.py
@@ -5,8 +5,8 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.identity.auth.common import _user_payload
-from hub_platform.identity.invitation_service import InvitationError, accept_invitation
+from chatballs.identity.auth.common import _user_payload
+from chatballs.identity.invitation_service import InvitationError, accept_invitation
class InvitationAcceptView(APIView):
diff --git a/apps/backend/hub_platform/identity/auth/password_reset.py b/apps/backend/chatballs/identity/auth/password_reset.py
similarity index 93%
rename from apps/backend/hub_platform/identity/auth/password_reset.py
rename to apps/backend/chatballs/identity/auth/password_reset.py
index a0e3394..40bdbb3 100644
--- a/apps/backend/hub_platform/identity/auth/password_reset.py
+++ b/apps/backend/chatballs/identity/auth/password_reset.py
@@ -11,10 +11,10 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
-from hub_platform.events.services import DomainEvent, enqueue_event
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.event_handlers import PASSWORD_RESET_REQUESTED
-from hub_platform.identity.models import AuditResult, HumanUser
+from chatballs.events.services import DomainEvent, enqueue_event
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.event_handlers import PASSWORD_RESET_REQUESTED
+from chatballs.identity.models import AuditResult, HumanUser
def _user_from_reset_link(uid: str, token: str) -> HumanUser | None:
diff --git a/apps/backend/hub_platform/identity/auth/profile.py b/apps/backend/chatballs/identity/auth/profile.py
similarity index 95%
rename from apps/backend/hub_platform/identity/auth/profile.py
rename to apps/backend/chatballs/identity/auth/profile.py
index 265dec5..5e0712a 100644
--- a/apps/backend/hub_platform/identity/auth/profile.py
+++ b/apps/backend/chatballs/identity/auth/profile.py
@@ -8,11 +8,11 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.auth.common import _revoke_other_user_sessions, _user_payload
-from hub_platform.tenancy.ingress import user_requires_totp
-from hub_platform.identity.avatars import delete_user_avatar, replace_user_avatar
-from hub_platform.identity.models import HumanUser
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
+from chatballs.tenancy.ingress import user_requires_totp
+from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
+from chatballs.identity.models import HumanUser
class ProfileUpdateView(APIView):
@@ -198,7 +198,7 @@ class ProfileAppearanceView(APIView):
def post(self, request: Request) -> Response:
import re
- from hub_platform.identity.models import UiTheme
+ from chatballs.identity.models import UiTheme
body = request.data
theme = str(body.get("theme", request.user.ui_theme)).strip().upper()
diff --git a/apps/backend/hub_platform/identity/auth/sessions.py b/apps/backend/chatballs/identity/auth/sessions.py
similarity index 90%
rename from apps/backend/hub_platform/identity/auth/sessions.py
rename to apps/backend/chatballs/identity/auth/sessions.py
index 2ffc8a6..f6bd9a6 100644
--- a/apps/backend/hub_platform/identity/auth/sessions.py
+++ b/apps/backend/chatballs/identity/auth/sessions.py
@@ -7,10 +7,10 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.auth.common import _challenge_payload, _user_payload
-from hub_platform.identity.auth.totp_utils import TOTP_SESSION_KEY
-from hub_platform.identity.models import AuditResult
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.auth.common import _challenge_payload, _user_payload
+from chatballs.identity.auth.totp_utils import TOTP_SESSION_KEY
+from chatballs.identity.models import AuditResult
@method_decorator(ensure_csrf_cookie, name="dispatch")
diff --git a/apps/backend/hub_platform/identity/auth/totp.py b/apps/backend/chatballs/identity/auth/totp.py
similarity index 94%
rename from apps/backend/hub_platform/identity/auth/totp.py
rename to apps/backend/chatballs/identity/auth/totp.py
index ec33953..f908242 100644
--- a/apps/backend/hub_platform/identity/auth/totp.py
+++ b/apps/backend/chatballs/identity/auth/totp.py
@@ -9,16 +9,16 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.auth.common import _user_payload
-from hub_platform.identity.auth.totp_utils import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.auth.common import _user_payload
+from chatballs.identity.auth.totp_utils import (
TOTP_ISSUER,
TOTP_PERIOD_SECONDS,
TOTP_SESSION_KEY,
_ensure_totp_secret,
_verify_totp,
)
-from hub_platform.identity.models import AuditResult, HumanUser
+from chatballs.identity.models import AuditResult, HumanUser
class TotpSetupView(APIView):
diff --git a/apps/backend/hub_platform/identity/auth/totp_utils.py b/apps/backend/chatballs/identity/auth/totp_utils.py
similarity index 96%
rename from apps/backend/hub_platform/identity/auth/totp_utils.py
rename to apps/backend/chatballs/identity/auth/totp_utils.py
index 892e4a0..ae6f825 100644
--- a/apps/backend/hub_platform/identity/auth/totp_utils.py
+++ b/apps/backend/chatballs/identity/auth/totp_utils.py
@@ -5,7 +5,7 @@ import os
import struct
import time
-from hub_platform.identity.models import HumanUser
+from chatballs.identity.models import HumanUser
TOTP_SESSION_KEY = "identity_pending_totp_user_id"
TOTP_ISSUER = "Chatballs"
diff --git a/apps/backend/hub_platform/identity/auth_urls.py b/apps/backend/chatballs/identity/auth_urls.py
similarity index 97%
rename from apps/backend/hub_platform/identity/auth_urls.py
rename to apps/backend/chatballs/identity/auth_urls.py
index 27cf638..eb9ff63 100644
--- a/apps/backend/hub_platform/identity/auth_urls.py
+++ b/apps/backend/chatballs/identity/auth_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.identity import auth
+from chatballs.identity import auth
urlpatterns = [
path("session/", auth.SessionView.as_view(), name="auth-session"),
diff --git a/apps/backend/hub_platform/identity/avatars.py b/apps/backend/chatballs/identity/avatars.py
similarity index 96%
rename from apps/backend/hub_platform/identity/avatars.py
rename to apps/backend/chatballs/identity/avatars.py
index 2d4ff32..96a4580 100644
--- a/apps/backend/hub_platform/identity/avatars.py
+++ b/apps/backend/chatballs/identity/avatars.py
@@ -16,7 +16,7 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from rest_framework.exceptions import ValidationError
-from hub_platform.identity.models import HumanUser
+from chatballs.identity.models import HumanUser
MAX_AVATAR_BYTES = 2 * 1024 * 1024
@@ -37,7 +37,7 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non
@functools.lru_cache(maxsize=4096)
def organization_public_id(organization_id: int) -> str:
"""public_id организации по id — неизменяем, поэтому кэшируется."""
- from hub_platform.identity.models import Organization
+ from chatballs.identity.models import Organization
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
diff --git a/apps/backend/hub_platform/identity/bootstrap.py b/apps/backend/chatballs/identity/bootstrap.py
similarity index 92%
rename from apps/backend/hub_platform/identity/bootstrap.py
rename to apps/backend/chatballs/identity/bootstrap.py
index 9610810..f06d834 100644
--- a/apps/backend/hub_platform/identity/bootstrap.py
+++ b/apps/backend/chatballs/identity/bootstrap.py
@@ -2,15 +2,15 @@ from dataclasses import dataclass
from django.db import transaction
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember
-from hub_platform.identity.models import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.products.models import Product
+from chatballs.products.models import Product
@dataclass(frozen=True)
@@ -32,7 +32,7 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
"currency": "RUB",
},
)
- from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
+ from chatballs.ai.knowledge_categories import ensure_uncategorized_category
ensure_uncategorized_category(organization)
# Группы сотрудников (ADR-HUB-0043): не обязательны для запуска, но дают
diff --git a/apps/backend/hub_platform/identity/capabilities.py b/apps/backend/chatballs/identity/capabilities.py
similarity index 100%
rename from apps/backend/hub_platform/identity/capabilities.py
rename to apps/backend/chatballs/identity/capabilities.py
diff --git a/apps/backend/hub_platform/identity/company_urls.py b/apps/backend/chatballs/identity/company_urls.py
similarity index 91%
rename from apps/backend/hub_platform/identity/company_urls.py
rename to apps/backend/chatballs/identity/company_urls.py
index 4c50df4..4faa5e3 100644
--- a/apps/backend/hub_platform/identity/company_urls.py
+++ b/apps/backend/chatballs/identity/company_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.identity import administration_views, demo_views, group_views
+from chatballs.identity import administration_views, demo_views, group_views
urlpatterns = [
path("groups/", group_views.GroupListView.as_view(), name="group-list"),
diff --git a/apps/backend/hub_platform/identity/crypto.py b/apps/backend/chatballs/identity/crypto.py
similarity index 95%
rename from apps/backend/hub_platform/identity/crypto.py
rename to apps/backend/chatballs/identity/crypto.py
index 1e01513..485c164 100644
--- a/apps/backend/hub_platform/identity/crypto.py
+++ b/apps/backend/chatballs/identity/crypto.py
@@ -9,7 +9,7 @@ from django.db import models
@lru_cache(maxsize=1)
def _fernet() -> Fernet:
- configured = getattr(settings, "CUS_FIELD_ENCRYPTION_KEY", "")
+ configured = getattr(settings, "CHATBALLS_FIELD_ENCRYPTION_KEY", "")
if configured:
return Fernet(configured.encode() if isinstance(configured, str) else configured)
# Dev/тестовый фолбэк: детерминированный ключ из SECRET_KEY (в production задаётся отдельно).
diff --git a/apps/backend/hub_platform/identity/demo_models.py b/apps/backend/chatballs/identity/demo_models.py
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_models.py
rename to apps/backend/chatballs/identity/demo_models.py
diff --git a/apps/backend/hub_platform/identity/demo_seed/__init__.py b/apps/backend/chatballs/identity/demo_seed/__init__.py
similarity index 78%
rename from apps/backend/hub_platform/identity/demo_seed/__init__.py
rename to apps/backend/chatballs/identity/demo_seed/__init__.py
index a8ae20e..a60cf2a 100644
--- a/apps/backend/hub_platform/identity/demo_seed/__init__.py
+++ b/apps/backend/chatballs/identity/demo_seed/__init__.py
@@ -1,11 +1,11 @@
"""Демонстрационные данные для локального тестирования и установки облака.
Пакет читает редактируемые JSON-манифесты из :mod:`demo_seed.data` и через
-:func:`hub_platform.identity.demo_seed.orchestrator.run_demo_seed` создаёт
+:func:`chatballs.identity.demo_seed.orchestrator.run_demo_seed` создаёт
полный набор выдуманных данных организации «Северная Верфь». Идемпотентен:
повторный запуск не дублирует и не затирает существующие записи.
"""
-from hub_platform.identity.demo_seed.orchestrator import run_demo_seed
+from chatballs.identity.demo_seed.orchestrator import run_demo_seed
__all__ = ["run_demo_seed"]
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json b/apps/backend/chatballs/identity/demo_seed/data/catalog.json
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/catalog.json
rename to apps/backend/chatballs/identity/demo_seed/data/catalog.json
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json b/apps/backend/chatballs/identity/demo_seed/data/channels_ai.json
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json
rename to apps/backend/chatballs/identity/demo_seed/data/channels_ai.json
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/conversations.json b/apps/backend/chatballs/identity/demo_seed/data/conversations.json
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/conversations.json
rename to apps/backend/chatballs/identity/demo_seed/data/conversations.json
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/anna-kim.png b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/anna-kim.png
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/anna-kim.png
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/anna-kim.png
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/dmitry-orlov.png b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/dmitry-orlov.png
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/dmitry-orlov.png
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/dmitry-orlov.png
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/elena-kuznetsova.png b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/elena-kuznetsova.png
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/elena-kuznetsova.png
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/elena-kuznetsova.png
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/irina-levina.jpg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/irina-levina.jpg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/maria-sokolova.jpg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/maria-sokolova.jpg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/olga-melnik.jpg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/olga-melnik.jpg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/sergey-krylov.jpg b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/sergey-krylov.jpg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/sergey-krylov.jpg
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/sergey-krylov.jpg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/svetlana-petrova.jpg b/apps/backend/chatballs/identity/demo_seed/data/media/avatars/svetlana-petrova.jpg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/avatars/svetlana-petrova.jpg
rename to apps/backend/chatballs/identity/demo_seed/data/media/avatars/svetlana-petrova.jpg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/dostavka-i-oplata.txt b/apps/backend/chatballs/identity/demo_seed/data/media/dostavka-i-oplata.txt
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/dostavka-i-oplata.txt
rename to apps/backend/chatballs/identity/demo_seed/data/media/dostavka-i-oplata.txt
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.pdf b/apps/backend/chatballs/identity/demo_seed/data/media/katalog-tkanej-2026.pdf
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.pdf
rename to apps/backend/chatballs/identity/demo_seed/data/media/katalog-tkanej-2026.pdf
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt b/apps/backend/chatballs/identity/demo_seed/data/media/katalog-tkanej-2026.txt
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt
rename to apps/backend/chatballs/identity/demo_seed/data/media/katalog-tkanej-2026.txt
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md b/apps/backend/chatballs/identity/demo_seed/data/media/raschet-stoimosti.md
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md
rename to apps/backend/chatballs/identity/demo_seed/data/media/raschet-stoimosti.md
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md b/apps/backend/chatballs/identity/demo_seed/data/media/voice/README.md
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md
rename to apps/backend/chatballs/identity/demo_seed/data/media/voice/README.md
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/artem-prepayment.ogg b/apps/backend/chatballs/identity/demo_seed/data/media/voice/artem-prepayment.ogg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/voice/artem-prepayment.ogg
rename to apps/backend/chatballs/identity/demo_seed/data/media/voice/artem-prepayment.ogg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/elena-start-with-numbers.ogg b/apps/backend/chatballs/identity/demo_seed/data/media/voice/elena-start-with-numbers.ogg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/voice/elena-start-with-numbers.ogg
rename to apps/backend/chatballs/identity/demo_seed/data/media/voice/elena-start-with-numbers.ogg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/olga-eighty-cm.ogg b/apps/backend/chatballs/identity/demo_seed/data/media/voice/olga-eighty-cm.ogg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/voice/olga-eighty-cm.ogg
rename to apps/backend/chatballs/identity/demo_seed/data/media/voice/olga-eighty-cm.ogg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/olga-saturday-post.ogg b/apps/backend/chatballs/identity/demo_seed/data/media/voice/olga-saturday-post.ogg
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/media/voice/olga-saturday-post.ogg
rename to apps/backend/chatballs/identity/demo_seed/data/media/voice/olga-saturday-post.ogg
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/operations.json b/apps/backend/chatballs/identity/demo_seed/data/operations.json
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/operations.json
rename to apps/backend/chatballs/identity/demo_seed/data/operations.json
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/organization.json b/apps/backend/chatballs/identity/demo_seed/data/organization.json
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/organization.json
rename to apps/backend/chatballs/identity/demo_seed/data/organization.json
diff --git a/apps/backend/hub_platform/identity/demo_seed/data/support.json b/apps/backend/chatballs/identity/demo_seed/data/support.json
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/data/support.json
rename to apps/backend/chatballs/identity/demo_seed/data/support.json
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/__init__.py b/apps/backend/chatballs/identity/demo_seed/loaders/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/__init__.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/__init__.py
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py b/apps/backend/chatballs/identity/demo_seed/loaders/catalog.py
similarity index 74%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/catalog.py
index 0ac5f38..75f68c8 100644
--- a/apps/backend/hub_platform/identity/demo_seed/loaders/catalog.py
+++ b/apps/backend/chatballs/identity/demo_seed/loaders/catalog.py
@@ -2,10 +2,10 @@
from __future__ import annotations
-from hub_platform.identity.demo_seed import manifest
-from hub_platform.identity.demo_seed.refs import DemoRefs
-from hub_platform.products.models import Product, ProductStatus
-from hub_platform.tenancy.context import TenantContext
+from chatballs.identity.demo_seed import manifest
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.products.models import Product, ProductStatus
+from chatballs.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py
similarity index 91%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py
index 8794db3..d6eaabb 100644
--- a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py
+++ b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py
@@ -6,25 +6,25 @@ 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 (
+from chatballs.ai.agent_card import ensure_channel_agent
+from chatballs.ai.knowledge_categories import create_category, ensure_uncategorized_category
+from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
+from chatballs.ai.models import (
AIAgent,
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
+from chatballs.channels.models import Channel
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.demo_seed import manifest
+from chatballs.identity.demo_seed.loaders.common import backdate, now, rng
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationStatus
+from chatballs.integrations.services import IntegrationInput, create_integration
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.storage_quota import finalize_storage, reserve_storage
+from chatballs.webchat.widgets import ensure_widget
def load(context: TenantContext, refs: DemoRefs) -> None:
@@ -146,7 +146,7 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
def _ensure_categories(context: TenantContext, refs: DemoRefs, items: list[dict], *, parent) -> None:
- from hub_platform.ai.knowledge_models import KnowledgeCategory
+ from chatballs.ai.knowledge_models import KnowledgeCategory
for index, item in enumerate(items, start=1):
category = KnowledgeCategory.objects.filter(
@@ -161,7 +161,7 @@ def _ensure_categories(context: TenantContext, refs: DemoRefs, items: list[dict]
def _ensure_knowledge(context: TenantContext, refs: DemoRefs, item: dict, current) -> None:
- from hub_platform.ai.models import Knowledge
+ from chatballs.ai.models import Knowledge
organization = refs.organization
knowledge = Knowledge.objects.filter(organization=organization, title=item["title"]).first()
@@ -212,7 +212,7 @@ def _attach_knowledge_file(context: TenantContext, refs: DemoRefs, knowledge, sp
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
+ from chatballs.ai.indexing import reindex_knowledge
reindex_knowledge(knowledge)
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/common.py b/apps/backend/chatballs/identity/demo_seed/loaders/common.py
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/common.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/common.py
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py
similarity index 96%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/conversations.py
index d6708a3..32f6517 100644
--- a/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py
+++ b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py
@@ -8,7 +8,7 @@ from datetime import timedelta
from django.core.files.base import ContentFile
-from hub_platform.conversations.models import (
+from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ConversationLabel,
@@ -20,11 +20,11 @@ from hub_platform.conversations.models import (
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
+from chatballs.identity.demo_seed import manifest
+from chatballs.identity.demo_seed.loaders.common import backdate, moment, now
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.tenancy.context import TenantContext
+from chatballs.webchat.services import issue_session
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py b/apps/backend/chatballs/identity/demo_seed/loaders/foundation.py
similarity index 89%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/foundation.py
index 5dd3930..99b7dce 100644
--- a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py
+++ b/apps/backend/chatballs/identity/demo_seed/loaders/foundation.py
@@ -9,16 +9,16 @@ from __future__ import annotations
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.avatars import image_type, set_user_avatar
-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.invitation_service import issue_invitation
-from hub_platform.identity.models import HumanUser, OrganizationMembership
-from hub_platform.tenancy.context import TenantContext
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.auth.totp_utils import _generate_totp_secret
+from chatballs.identity.avatars import image_type, set_user_avatar
+from chatballs.identity.demo_seed import manifest
+from chatballs.identity.demo_seed.loaders.common import backdate, moment, now
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
+from chatballs.identity.invitation_service import issue_invitation
+from chatballs.identity.models import HumanUser, OrganizationMembership
+from chatballs.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py
similarity index 95%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/operations.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/operations.py
index 1bb0bba..3caad0a 100644
--- a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py
+++ b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py
@@ -6,7 +6,7 @@ from __future__ import annotations
import hashlib
from datetime import timedelta
-from hub_platform.calls.models import (
+from chatballs.calls.models import (
CallConnectionType,
CallEndedBy,
CallInvite,
@@ -18,10 +18,10 @@ from hub_platform.calls.models import (
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 (
+from chatballs.identity.demo_seed import manifest
+from chatballs.identity.demo_seed.loaders.common import backdate, moment, now
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.notifications.models import (
MessengerBinding,
MessengerBindingCode,
Notification,
@@ -29,7 +29,7 @@ from hub_platform.notifications.models import (
NotificationLevel,
NotificationRead,
)
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
FINISHED = {
CallStatus.ENDED,
diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py b/apps/backend/chatballs/identity/demo_seed/loaders/support.py
similarity index 93%
rename from apps/backend/hub_platform/identity/demo_seed/loaders/support.py
rename to apps/backend/chatballs/identity/demo_seed/loaders/support.py
index c51aa41..d027e76 100644
--- a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py
+++ b/apps/backend/chatballs/identity/demo_seed/loaders/support.py
@@ -5,15 +5,15 @@ from __future__ import annotations
from datetime import timedelta
-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 (
+from chatballs.identity.demo_seed import manifest
+from chatballs.identity.demo_seed.loaders.common import backdate, now
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
-from hub_platform.support_portals.content_services import (
+from chatballs.support_portals.content_services import (
add_revision,
archive_article,
create_article,
@@ -21,15 +21,15 @@ from hub_platform.support_portals.content_services import (
publish_revision,
record_feedback,
)
-from hub_platform.support_portals.models import PortalCategory, SupportPortal
-from hub_platform.support_portals.portal_services import (
+from chatballs.support_portals.models import PortalCategory, SupportPortal
+from chatballs.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
+from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
+from chatballs.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
diff --git a/apps/backend/hub_platform/identity/demo_seed/manifest.py b/apps/backend/chatballs/identity/demo_seed/manifest.py
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/manifest.py
rename to apps/backend/chatballs/identity/demo_seed/manifest.py
diff --git a/apps/backend/hub_platform/identity/demo_seed/orchestrator.py b/apps/backend/chatballs/identity/demo_seed/orchestrator.py
similarity index 82%
rename from apps/backend/hub_platform/identity/demo_seed/orchestrator.py
rename to apps/backend/chatballs/identity/demo_seed/orchestrator.py
index f5274f6..f881432 100644
--- a/apps/backend/hub_platform/identity/demo_seed/orchestrator.py
+++ b/apps/backend/chatballs/identity/demo_seed/orchestrator.py
@@ -8,26 +8,26 @@ foundation → catalog → channels/ai → support → conversations → operati
from __future__ import annotations
-from hub_platform.identity.demo_seed.loaders import (
+from chatballs.identity.demo_seed.loaders import (
catalog as catalog_loader,
)
-from hub_platform.identity.demo_seed.loaders import (
+from chatballs.identity.demo_seed.loaders import (
channels_ai as channels_ai_loader,
)
-from hub_platform.identity.demo_seed.loaders import (
+from chatballs.identity.demo_seed.loaders import (
conversations as conversations_loader,
)
-from hub_platform.identity.demo_seed.loaders import (
+from chatballs.identity.demo_seed.loaders import (
foundation as foundation_loader,
)
-from hub_platform.identity.demo_seed.loaders import (
+from chatballs.identity.demo_seed.loaders import (
operations as operations_loader,
)
-from hub_platform.identity.demo_seed.loaders import (
+from chatballs.identity.demo_seed.loaders import (
support as support_loader,
)
-from hub_platform.identity.demo_seed.refs import DemoRefs
-from hub_platform.tenancy.context import TenantContext
+from chatballs.identity.demo_seed.refs import DemoRefs
+from chatballs.tenancy.context import TenantContext
__all__ = ["DemoRefs", "run_demo_seed"]
diff --git a/apps/backend/hub_platform/identity/demo_seed/refs.py b/apps/backend/chatballs/identity/demo_seed/refs.py
similarity index 100%
rename from apps/backend/hub_platform/identity/demo_seed/refs.py
rename to apps/backend/chatballs/identity/demo_seed/refs.py
diff --git a/apps/backend/hub_platform/identity/demo_seed/registry.py b/apps/backend/chatballs/identity/demo_seed/registry.py
similarity index 76%
rename from apps/backend/hub_platform/identity/demo_seed/registry.py
rename to apps/backend/chatballs/identity/demo_seed/registry.py
index a3e8bf3..1818452 100644
--- a/apps/backend/hub_platform/identity/demo_seed/registry.py
+++ b/apps/backend/chatballs/identity/demo_seed/registry.py
@@ -19,11 +19,11 @@ 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 import IntegrityError, 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
+from chatballs.identity.demo_models import DemoDataset, DemoRecord
logger = logging.getLogger(__name__)
@@ -113,7 +113,11 @@ def _detach_demo_references(instance: models.Model, demo_keys: set[tuple[int, st
def _delete_record(record: DemoRecord, demo_keys: set[tuple[int, str]]) -> bool:
"""Удаляет запись реестра и её объект. ``False`` — объект защищён
- (PROTECT) ещё живой записью; попытка повторяется позже."""
+ (PROTECT) ещё живой записью; попытка повторяется позже.
+
+ Защищающие объекты (звонок ↔ диалог, статья ↔ ревизия) удаляются первыми,
+ затем повтор — они не могут жить без демо-объекта. Обнуление ссылок — последний шанс для
+ циклов; если оно ломает check-constraint, объект просто откладывается."""
model = record.content_type.model_class()
if model is None:
record.delete()
@@ -122,16 +126,46 @@ def _delete_record(record: DemoRecord, demo_keys: set[tuple[int, str]]) -> bool:
if instance is None:
record.delete()
return True
- for attempt in range(2):
+ for attempt in range(3):
try:
with transaction.atomic():
_delete_files(instance)
instance.delete()
break
- except ProtectedError:
- if attempt == 1 or not _detach_demo_references(instance, demo_keys):
+ except ProtectedError as error:
+ # Защищающие объекты (звонок по демо-диалогу, ревизия демо-статьи)
+ # не могут существовать без демо-объекта — удаляются вместе с ним,
+ # даже если их создал тестировавший человек, а не сид.
+ protectors = list(error.protected_objects)
+ foreign = [
+ obj for obj in protectors
+ if (ContentType.objects.get_for_model(type(obj), for_concrete_model=True).id, str(obj.pk)) not in demo_keys
+ ]
+ if foreign and attempt == 0:
+ logger.warning(
+ "Demo removal: %s %s is referenced by %d non-demo object(s) created on top of the demo — removing them too",
+ model.__name__, record.object_id, len(foreign),
+ )
+ if attempt == 0:
+ for obj in protectors:
+ try:
+ with transaction.atomic():
+ _delete_files(obj)
+ obj.delete()
+ except ProtectedError:
+ pass
+ elif attempt == 1:
+ try:
+ with transaction.atomic():
+ if not _detach_demo_references(instance, demo_keys):
+ return False
+ except IntegrityError:
+ return False
+ else:
return False
- instance = model._base_manager.get(pk=record.object_id)
+ instance = model._base_manager.filter(pk=record.object_id).first()
+ if instance is None:
+ break
record.delete()
return True
diff --git a/apps/backend/hub_platform/identity/demo_seed/service.py b/apps/backend/chatballs/identity/demo_seed/service.py
similarity index 93%
rename from apps/backend/hub_platform/identity/demo_seed/service.py
rename to apps/backend/chatballs/identity/demo_seed/service.py
index 616991f..17c7acc 100644
--- a/apps/backend/hub_platform/identity/demo_seed/service.py
+++ b/apps/backend/chatballs/identity/demo_seed/service.py
@@ -14,17 +14,17 @@ 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
+from chatballs.events.services import DomainEvent, enqueue_event
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus
+from chatballs.identity.demo_seed.orchestrator import run_demo_seed
+from chatballs.identity.demo_seed.registry import recording, remove_records
+from chatballs.identity.models import HumanUser, Organization
+from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
-from hub_platform.identity.event_handlers import ( # noqa: E402
+from chatballs.identity.event_handlers import ( # noqa: E402
DEMO_INSTALL_REQUESTED,
DEMO_REMOVE_REQUESTED,
)
@@ -37,7 +37,7 @@ class DemoBusy(ValidationError):
def showcase_accounts() -> list[dict[str, object]]:
"""Учётки для входа «посмотреть глазами сотрудника»: админ и по одному
сотруднику из разных групп. Пароль общий и намеренно публичный — это демо."""
- from hub_platform.identity.demo_seed import manifest
+ from chatballs.identity.demo_seed import manifest
data = manifest.load("organization")
by_key = {item["key"]: item for item in data["accounts"]}
diff --git a/apps/backend/hub_platform/identity/demo_views.py b/apps/backend/chatballs/identity/demo_views.py
similarity index 96%
rename from apps/backend/hub_platform/identity/demo_views.py
rename to apps/backend/chatballs/identity/demo_views.py
index daf7e83..abf463b 100644
--- a/apps/backend/hub_platform/identity/demo_views.py
+++ b/apps/backend/chatballs/identity/demo_views.py
@@ -9,8 +9,8 @@ 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
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.demo_seed import manifest, service
def _error(error: ValidationError, status: int) -> Response:
diff --git a/apps/backend/hub_platform/identity/emails.py b/apps/backend/chatballs/identity/emails.py
similarity index 97%
rename from apps/backend/hub_platform/identity/emails.py
rename to apps/backend/chatballs/identity/emails.py
index ae32efe..5844bbf 100644
--- a/apps/backend/hub_platform/identity/emails.py
+++ b/apps/backend/chatballs/identity/emails.py
@@ -4,7 +4,7 @@ from django.core.mail import send_mail
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
-from hub_platform.identity.models import HumanUser
+from chatballs.identity.models import HumanUser
def _password_setup_url(user: HumanUser) -> str:
diff --git a/apps/backend/hub_platform/identity/employee_security_views.py b/apps/backend/chatballs/identity/employee_security_views.py
similarity index 90%
rename from apps/backend/hub_platform/identity/employee_security_views.py
rename to apps/backend/chatballs/identity/employee_security_views.py
index f85357b..96c7754 100644
--- a/apps/backend/hub_platform/identity/employee_security_views.py
+++ b/apps/backend/chatballs/identity/employee_security_views.py
@@ -4,10 +4,10 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.employee_support import employee_payload, get_owned_profile
-from hub_platform.identity.employee_validation import deny_employee_action
-from hub_platform.identity.governance import EmployeeAction, can_manage_employee
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.employee_support import employee_payload, get_owned_profile
+from chatballs.identity.employee_validation import deny_employee_action
+from chatballs.identity.governance import EmployeeAction, can_manage_employee
class EmployeeResetPasswordView(APIView):
diff --git a/apps/backend/hub_platform/identity/employee_support.py b/apps/backend/chatballs/identity/employee_support.py
similarity index 90%
rename from apps/backend/hub_platform/identity/employee_support.py
rename to apps/backend/chatballs/identity/employee_support.py
index 47d3e30..5a5ce67 100644
--- a/apps/backend/hub_platform/identity/employee_support.py
+++ b/apps/backend/chatballs/identity/employee_support.py
@@ -1,8 +1,8 @@
from rest_framework.request import Request
-from hub_platform.identity.governance import employee_management_flags
-from hub_platform.identity.avatars import user_avatar_url
-from hub_platform.identity.models import AuditEvent, OrganizationMembership
+from chatballs.identity.governance import employee_management_flags
+from chatballs.identity.avatars import user_avatar_url
+from chatballs.identity.models import AuditEvent, OrganizationMembership
def employee_payload(
@@ -37,7 +37,7 @@ def employee_payload(
if actor is not None:
payload["permissions"] = employee_management_flags(actor, profile)
if include_detail:
- from hub_platform.identity.sessions import count_user_sessions
+ from chatballs.identity.sessions import count_user_sessions
payload["activeSessionCount"] = count_user_sessions(profile.user_id)
payload["auditEvents"] = [
diff --git a/apps/backend/hub_platform/identity/employee_urls.py b/apps/backend/chatballs/identity/employee_urls.py
similarity index 97%
rename from apps/backend/hub_platform/identity/employee_urls.py
rename to apps/backend/chatballs/identity/employee_urls.py
index 97ff8a2..e71f726 100644
--- a/apps/backend/hub_platform/identity/employee_urls.py
+++ b/apps/backend/chatballs/identity/employee_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.identity import (
+from chatballs.identity import (
employee_security_views,
employee_views,
ownership_views,
diff --git a/apps/backend/hub_platform/identity/employee_validation.py b/apps/backend/chatballs/identity/employee_validation.py
similarity index 94%
rename from apps/backend/hub_platform/identity/employee_validation.py
rename to apps/backend/chatballs/identity/employee_validation.py
index b0295d8..ee32c16 100644
--- a/apps/backend/hub_platform/identity/employee_validation.py
+++ b/apps/backend/chatballs/identity/employee_validation.py
@@ -1,8 +1,8 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.models import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.models import (
POSITION_TITLE_MAX_LENGTH,
AuditResult,
EmployeeGroup,
diff --git a/apps/backend/hub_platform/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py
similarity index 93%
rename from apps/backend/hub_platform/identity/employee_views.py
rename to apps/backend/chatballs/identity/employee_views.py
index cb47d57..7618c37 100644
--- a/apps/backend/hub_platform/identity/employee_views.py
+++ b/apps/backend/chatballs/identity/employee_views.py
@@ -5,20 +5,20 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.events.services import DomainEvent, enqueue_event
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.employee_support import employee_payload, get_owned_profile
-from hub_platform.identity.employee_validation import (
+from chatballs.events.services import DomainEvent, enqueue_event
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.employee_support import employee_payload, get_owned_profile
+from chatballs.identity.employee_validation import (
ASSIGNABLE_ROLES,
clean_position_title,
deny_employee_action,
resolve_groups,
)
-from hub_platform.identity.event_handlers import INITIAL_ACCESS_REQUESTED
-from hub_platform.identity.governance import EmployeeAction, can_create_role, can_manage_employee
-from hub_platform.identity.group_models import EmployeeGroupMember
-from hub_platform.identity.models import EmployeeRole, HumanUser, OrganizationMembership
-from hub_platform.identity.policy import has_capability_any_scope
+from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
+from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
+from chatballs.identity.group_models import EmployeeGroupMember
+from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership
+from chatballs.identity.policy import has_capability_any_scope
def _set_groups(profile: OrganizationMembership, groups) -> None:
diff --git a/apps/backend/hub_platform/identity/event_handlers.py b/apps/backend/chatballs/identity/event_handlers.py
similarity index 80%
rename from apps/backend/hub_platform/identity/event_handlers.py
rename to apps/backend/chatballs/identity/event_handlers.py
index b4d9c62..78064f1 100644
--- a/apps/backend/hub_platform/identity/event_handlers.py
+++ b/apps/backend/chatballs/identity/event_handlers.py
@@ -1,7 +1,7 @@
-from hub_platform.events.handlers import register
-from hub_platform.identity.emails import send_initial_access_email, send_password_reset_email
-from hub_platform.identity.models import HumanUser
-from hub_platform.tenancy.context import TenantContext
+from chatballs.events.handlers import register
+from chatballs.identity.emails import send_initial_access_email, send_password_reset_email
+from chatballs.identity.models import HumanUser
+from chatballs.tenancy.context import TenantContext
INITIAL_ACCESS_REQUESTED = "identity.initial_access_requested"
PASSWORD_RESET_REQUESTED = "identity.password_reset_requested"
@@ -46,7 +46,7 @@ DEMO_REMOVE_REQUESTED = "demo.remove_requested"
def _demo_dataset(payload: dict, context: TenantContext | None):
- from hub_platform.identity.demo_models import DemoDataset
+ from chatballs.identity.demo_models import DemoDataset
if context is None:
raise ValueError("Demo dataset events are tenant events")
@@ -59,8 +59,8 @@ def _demo_dataset(payload: dict, context: TenantContext | None):
@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
+ from chatballs.identity.demo_models import DemoDatasetStatus
+ from chatballs.identity.demo_seed import service
dataset = _demo_dataset(payload, context)
if dataset is None or dataset.status != DemoDatasetStatus.INSTALLING:
@@ -70,8 +70,8 @@ def handle_demo_install_requested(payload: dict, context: TenantContext | None)
@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
+ from chatballs.identity.demo_models import DemoDatasetStatus
+ from chatballs.identity.demo_seed import service
dataset = _demo_dataset(payload, context)
if dataset is None or dataset.status != DemoDatasetStatus.REMOVING:
diff --git a/apps/backend/hub_platform/identity/governance.py b/apps/backend/chatballs/identity/governance.py
similarity index 98%
rename from apps/backend/hub_platform/identity/governance.py
rename to apps/backend/chatballs/identity/governance.py
index b85d650..572e548 100644
--- a/apps/backend/hub_platform/identity/governance.py
+++ b/apps/backend/chatballs/identity/governance.py
@@ -10,7 +10,7 @@ OWNER и ADMIN идентичны по правам: оба управляют
from __future__ import annotations
-from hub_platform.identity.models import EmployeeRole, OrganizationMembership
+from chatballs.identity.models import EmployeeRole, OrganizationMembership
class EmployeeAction:
diff --git a/apps/backend/hub_platform/identity/group_models.py b/apps/backend/chatballs/identity/group_models.py
similarity index 94%
rename from apps/backend/hub_platform/identity/group_models.py
rename to apps/backend/chatballs/identity/group_models.py
index edd02aa..259c3bf 100644
--- a/apps/backend/hub_platform/identity/group_models.py
+++ b/apps/backend/chatballs/identity/group_models.py
@@ -4,8 +4,8 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.functions import Lower
-from hub_platform.identity.models import Organization, OrganizationMembership
-from hub_platform.tenancy.models import TenantRelationModel
+from chatballs.identity.models import Organization, OrganizationMembership
+from chatballs.tenancy.models import TenantRelationModel
# Настраиваемые группы сотрудников (ADR-HUB-0043): граница видимости диалогов
# и ничего больше — без прав, знаний, должностей и иерархии. Организация сама
diff --git a/apps/backend/hub_platform/identity/group_views.py b/apps/backend/chatballs/identity/group_views.py
similarity index 96%
rename from apps/backend/hub_platform/identity/group_views.py
rename to apps/backend/chatballs/identity/group_views.py
index cdd02be..789626a 100644
--- a/apps/backend/hub_platform/identity/group_views.py
+++ b/apps/backend/chatballs/identity/group_views.py
@@ -7,10 +7,10 @@ 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.audit import record_audit_event
-from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember
-from hub_platform.identity.models import OrganizationMembership
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
+from chatballs.identity.models import OrganizationMembership
# Группы сотрудников (ADR-HUB-0043): имя + состав, только граница видимости
# диалогов. Управляют OWNER/ADMIN; сотрудник видит свои группы в session payload.
diff --git a/apps/backend/hub_platform/identity/invitation_models.py b/apps/backend/chatballs/identity/invitation_models.py
similarity index 96%
rename from apps/backend/hub_platform/identity/invitation_models.py
rename to apps/backend/chatballs/identity/invitation_models.py
index 81471e8..b370678 100644
--- a/apps/backend/hub_platform/identity/invitation_models.py
+++ b/apps/backend/chatballs/identity/invitation_models.py
@@ -6,7 +6,7 @@ from django.db.models import Q
from django.db.models.functions import Lower
from django.utils import timezone
-from hub_platform.identity.models import EmployeeRole, Organization, OrganizationMembership
+from chatballs.identity.models import EmployeeRole, Organization, OrganizationMembership
class OrganizationInvitation(models.Model):
diff --git a/apps/backend/hub_platform/identity/invitation_service.py b/apps/backend/chatballs/identity/invitation_service.py
similarity index 97%
rename from apps/backend/hub_platform/identity/invitation_service.py
rename to apps/backend/chatballs/identity/invitation_service.py
index c1e6407..b57c889 100644
--- a/apps/backend/hub_platform/identity/invitation_service.py
+++ b/apps/backend/chatballs/identity/invitation_service.py
@@ -8,8 +8,8 @@ from datetime import datetime
from django.db import transaction
from django.utils import timezone
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.models import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
@@ -17,7 +17,7 @@ from hub_platform.identity.models import (
OrganizationMembership,
OrganizationStatus,
)
-from hub_platform.tenancy.database import tenant_atomic
+from chatballs.tenancy.database import tenant_atomic
def _token_hash(token: str) -> str:
diff --git a/apps/backend/hub_platform/identity/__init__.py b/apps/backend/chatballs/identity/management/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/identity/__init__.py
rename to apps/backend/chatballs/identity/management/__init__.py
diff --git a/apps/backend/hub_platform/identity/management/__init__.py b/apps/backend/chatballs/identity/management/commands/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/identity/management/__init__.py
rename to apps/backend/chatballs/identity/management/commands/__init__.py
diff --git a/apps/backend/hub_platform/identity/management/commands/seed_demo.py b/apps/backend/chatballs/identity/management/commands/seed_demo.py
similarity index 91%
rename from apps/backend/hub_platform/identity/management/commands/seed_demo.py
rename to apps/backend/chatballs/identity/management/commands/seed_demo.py
index d8a4a5a..e1d3e3a 100644
--- a/apps/backend/hub_platform/identity/management/commands/seed_demo.py
+++ b/apps/backend/chatballs/identity/management/commands/seed_demo.py
@@ -9,11 +9,11 @@ from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
-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
+from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus
+from chatballs.identity.demo_seed import service
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantActorKind, TenantContext
+from chatballs.tenancy.database import tenant_atomic
class Command(BaseCommand):
diff --git a/apps/backend/hub_platform/identity/migrations/0001_initial.py b/apps/backend/chatballs/identity/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0001_initial.py
rename to apps/backend/chatballs/identity/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/identity/migrations/0002_alter_humanuser_managers_and_more.py b/apps/backend/chatballs/identity/migrations/0002_alter_humanuser_managers_and_more.py
similarity index 96%
rename from apps/backend/hub_platform/identity/migrations/0002_alter_humanuser_managers_and_more.py
rename to apps/backend/chatballs/identity/migrations/0002_alter_humanuser_managers_and_more.py
index 29d1c21..1766b1b 100644
--- a/apps/backend/hub_platform/identity/migrations/0002_alter_humanuser_managers_and_more.py
+++ b/apps/backend/chatballs/identity/migrations/0002_alter_humanuser_managers_and_more.py
@@ -1,7 +1,7 @@
# Generated by Django 5.2.15 on 2026-06-17 15:23
import django.utils.timezone
-import hub_platform.identity.models
+import chatballs.identity.models
from django.db import migrations, models
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
migrations.AlterModelManagers(
name='humanuser',
managers=[
- ('objects', hub_platform.identity.models.HumanUserManager()),
+ ('objects', chatballs.identity.models.HumanUserManager()),
],
),
migrations.RenameIndex(
diff --git a/apps/backend/hub_platform/identity/migrations/0003_employeeprofile_totp_secret.py b/apps/backend/chatballs/identity/migrations/0003_employeeprofile_totp_secret.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0003_employeeprofile_totp_secret.py
rename to apps/backend/chatballs/identity/migrations/0003_employeeprofile_totp_secret.py
diff --git a/apps/backend/hub_platform/identity/migrations/0004_employeeprofile_phone.py b/apps/backend/chatballs/identity/migrations/0004_employeeprofile_phone.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0004_employeeprofile_phone.py
rename to apps/backend/chatballs/identity/migrations/0004_employeeprofile_phone.py
diff --git a/apps/backend/hub_platform/identity/migrations/0005_move_product_state.py b/apps/backend/chatballs/identity/migrations/0005_move_product_state.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0005_move_product_state.py
rename to apps/backend/chatballs/identity/migrations/0005_move_product_state.py
diff --git a/apps/backend/hub_platform/identity/migrations/0006_alter_employeeprofile_totp_secret.py b/apps/backend/chatballs/identity/migrations/0006_alter_employeeprofile_totp_secret.py
similarity index 83%
rename from apps/backend/hub_platform/identity/migrations/0006_alter_employeeprofile_totp_secret.py
rename to apps/backend/chatballs/identity/migrations/0006_alter_employeeprofile_totp_secret.py
index 030e898..a2608ac 100644
--- a/apps/backend/hub_platform/identity/migrations/0006_alter_employeeprofile_totp_secret.py
+++ b/apps/backend/chatballs/identity/migrations/0006_alter_employeeprofile_totp_secret.py
@@ -1,13 +1,13 @@
# Generated by Django 5.2.15 on 2026-06-23 15:19
-import hub_platform.identity.crypto
+import chatballs.identity.crypto
from django.db import migrations
_TABLE = "identity_employeeprofile"
def _encrypt_existing(apps, schema_editor):
- from hub_platform.identity.crypto import encrypt_secret
+ from chatballs.identity.crypto import encrypt_secret
with schema_editor.connection.cursor() as cursor:
cursor.execute(f"SELECT id, totp_secret FROM {_TABLE} WHERE totp_secret <> ''")
@@ -18,7 +18,7 @@ def _encrypt_existing(apps, schema_editor):
def _decrypt_existing(apps, schema_editor):
- from hub_platform.identity.crypto import decrypt_secret
+ from chatballs.identity.crypto import decrypt_secret
with schema_editor.connection.cursor() as cursor:
cursor.execute(f"SELECT id, totp_secret FROM {_TABLE} WHERE totp_secret <> ''")
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='employeeprofile',
name='totp_secret',
- field=hub_platform.identity.crypto.EncryptedCharField(blank=True, max_length=255),
+ field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=255),
),
migrations.RunPython(_encrypt_existing, _decrypt_existing),
]
diff --git a/apps/backend/hub_platform/identity/migrations/0007_employee_roles_position_department.py b/apps/backend/chatballs/identity/migrations/0007_employee_roles_position_department.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0007_employee_roles_position_department.py
rename to apps/backend/chatballs/identity/migrations/0007_employee_roles_position_department.py
diff --git a/apps/backend/hub_platform/identity/migrations/0008_enforce_position_title.py b/apps/backend/chatballs/identity/migrations/0008_enforce_position_title.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0008_enforce_position_title.py
rename to apps/backend/chatballs/identity/migrations/0008_enforce_position_title.py
diff --git a/apps/backend/hub_platform/identity/migrations/0009_access_profiles_and_assignments.py b/apps/backend/chatballs/identity/migrations/0009_access_profiles_and_assignments.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0009_access_profiles_and_assignments.py
rename to apps/backend/chatballs/identity/migrations/0009_access_profiles_and_assignments.py
diff --git a/apps/backend/hub_platform/identity/migrations/0010_backfill_employee_access.py b/apps/backend/chatballs/identity/migrations/0010_backfill_employee_access.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0010_backfill_employee_access.py
rename to apps/backend/chatballs/identity/migrations/0010_backfill_employee_access.py
diff --git a/apps/backend/hub_platform/identity/migrations/0011_enforce_capability_registry.py b/apps/backend/chatballs/identity/migrations/0011_enforce_capability_registry.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0011_enforce_capability_registry.py
rename to apps/backend/chatballs/identity/migrations/0011_enforce_capability_registry.py
diff --git a/apps/backend/hub_platform/identity/migrations/0012_membership_identity.py b/apps/backend/chatballs/identity/migrations/0012_membership_identity.py
similarity index 98%
rename from apps/backend/hub_platform/identity/migrations/0012_membership_identity.py
rename to apps/backend/chatballs/identity/migrations/0012_membership_identity.py
index 57342e8..0efe710 100644
--- a/apps/backend/hub_platform/identity/migrations/0012_membership_identity.py
+++ b/apps/backend/chatballs/identity/migrations/0012_membership_identity.py
@@ -5,7 +5,7 @@ import django.db.models.functions.text
from django.conf import settings
from django.db import migrations, models
-import hub_platform.identity.crypto
+import chatballs.identity.crypto
def backfill_organization_public_ids(apps, schema_editor):
@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="humanuser",
name="totp_secret",
- field=hub_platform.identity.crypto.EncryptedCharField(blank=True, max_length=255),
+ field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=255),
),
migrations.AddField(
model_name="organization",
diff --git a/apps/backend/hub_platform/identity/migrations/0013_accessprofilecapability_organization_and_more.py b/apps/backend/chatballs/identity/migrations/0013_accessprofilecapability_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0013_accessprofilecapability_organization_and_more.py
rename to apps/backend/chatballs/identity/migrations/0013_accessprofilecapability_organization_and_more.py
diff --git a/apps/backend/hub_platform/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py b/apps/backend/chatballs/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py
rename to apps/backend/chatballs/identity/migrations/0014_alter_accessprofilecapability_organization_and_more.py
diff --git a/apps/backend/hub_platform/identity/migrations/0015_organization_status.py b/apps/backend/chatballs/identity/migrations/0015_organization_status.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0015_organization_status.py
rename to apps/backend/chatballs/identity/migrations/0015_organization_status.py
diff --git a/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py b/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py
new file mode 100644
index 0000000..4f7abe2
--- /dev/null
+++ b/apps/backend/chatballs/identity/migrations/0016_channel_capabilities.py
@@ -0,0 +1,82 @@
+# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
+#
+# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
+# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
+# момент выката.
+#
+# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
+# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
+# трогаем назначения: каждое действующее назначение профиля продолжает работать
+# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
+# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
+from django.db import migrations, models
+
+AI_VIEW = "ai.view"
+AI_MANAGE = "ai.manage"
+CHANNELS_VIEW = "channels.view"
+CHANNELS_MANAGE = "channels.manage"
+
+
+def _grant(apps, *, source: str, target: str) -> None:
+ AccessProfile = apps.get_model("identity", "AccessProfile")
+ AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
+
+ already_granted = set(
+ AccessProfileCapability.objects.filter(capability_code=target).values_list(
+ "access_profile_id", flat=True
+ )
+ )
+ # organization_id берётся у профиля, а не выводится: триггер
+ # chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.
+ profiles = (
+ AccessProfile.objects.filter(capability_links__capability_code=source)
+ .values_list("id", "organization_id")
+ .distinct()
+ )
+ AccessProfileCapability.objects.bulk_create(
+ [
+ AccessProfileCapability(
+ access_profile_id=profile_id,
+ organization_id=organization_id,
+ capability_code=target,
+ )
+ for profile_id, organization_id in profiles
+ if profile_id not in already_granted
+ ]
+ )
+
+
+def grant_channel_capabilities(apps, schema_editor):
+ _grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
+ _grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
+
+
+def revoke_channel_capabilities(apps, schema_editor):
+ # В отличие от 0010 откат обязан удалить выданные строки: следом
+ # восстанавливается прежний check-constraint реестра, и строки с кодом вне
+ # списка сделали бы обратную миграцию невыполнимой.
+ AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
+ AccessProfileCapability.objects.filter(
+ capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
+ ).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('identity', '0015_organization_status'),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name='accessprofilecapability',
+ name='access_profile_capability_registry',
+ ),
+ migrations.AddConstraint(
+ model_name='accessprofilecapability',
+ constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
+ ),
+ # Порядок важен: при откате Django исполняет операции в обратном порядке,
+ # поэтому строки удаляются до восстановления старого constraint.
+ migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
+ ]
diff --git a/apps/backend/hub_platform/identity/migrations/0017_organization_branding.py b/apps/backend/chatballs/identity/migrations/0017_organization_branding.py
similarity index 86%
rename from apps/backend/hub_platform/identity/migrations/0017_organization_branding.py
rename to apps/backend/chatballs/identity/migrations/0017_organization_branding.py
index 8bf2a86..7ccd34c 100644
--- a/apps/backend/hub_platform/identity/migrations/0017_organization_branding.py
+++ b/apps/backend/chatballs/identity/migrations/0017_organization_branding.py
@@ -1,6 +1,6 @@
from django.db import migrations, models
-import hub_platform.identity.models
+import chatballs.identity.models
class Migration(migrations.Migration):
@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
field=models.FileField(
blank=True,
max_length=512,
- upload_to=hub_platform.identity.models.organization_logo_upload_path,
+ upload_to=chatballs.identity.models.organization_logo_upload_path,
),
),
migrations.AddField(
diff --git a/apps/backend/hub_platform/identity/migrations/0018_organization_branding_db_defaults.py b/apps/backend/chatballs/identity/migrations/0018_organization_branding_db_defaults.py
similarity index 88%
rename from apps/backend/hub_platform/identity/migrations/0018_organization_branding_db_defaults.py
rename to apps/backend/chatballs/identity/migrations/0018_organization_branding_db_defaults.py
index 4477c88..db6e065 100644
--- a/apps/backend/hub_platform/identity/migrations/0018_organization_branding_db_defaults.py
+++ b/apps/backend/chatballs/identity/migrations/0018_organization_branding_db_defaults.py
@@ -1,6 +1,6 @@
from django.db import migrations, models
-import hub_platform.identity.models
+import chatballs.identity.models
class Migration(migrations.Migration):
@@ -17,7 +17,7 @@ class Migration(migrations.Migration):
db_default="",
default="",
max_length=512,
- upload_to=hub_platform.identity.models.organization_logo_upload_path,
+ upload_to=chatballs.identity.models.organization_logo_upload_path,
),
),
migrations.AlterField(
diff --git a/apps/backend/hub_platform/identity/migrations/0019_drop_sales_capabilities.py b/apps/backend/chatballs/identity/migrations/0019_drop_sales_capabilities.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0019_drop_sales_capabilities.py
rename to apps/backend/chatballs/identity/migrations/0019_drop_sales_capabilities.py
diff --git a/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py b/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py
new file mode 100644
index 0000000..d9f3ae9
--- /dev/null
+++ b/apps/backend/chatballs/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py
@@ -0,0 +1,174 @@
+# Generated by Django 5.2.15 on 2026-09-03 22:23
+
+import django.db.models.deletion
+import django.db.models.functions.text
+from django.db import migrations, models
+
+# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
+# грантов, которые исходно раздавала tenancy/0003 (она при откате не
+# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
+# читать identity_department и migration-тесты падают на старых состояниях.
+# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
+# откате (операции разворачиваются в обратном порядке), когда таблицы уже
+# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
+# тестами не используется, поэтому политики не восстанавливаем.
+_RESTORE_GRANTS_SQL = "\n".join(
+ f"""
+ALTER TABLE {table} OWNER TO chatballs_schema;
+GRANT ALL ON {table} TO chatballs_schema;
+GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
+"""
+ for table in (
+ "identity_department",
+ "identity_accessprofile",
+ "identity_accessprofilecapability",
+ "identity_employeeaccessassignment",
+ )
+)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ai', '0014_remove_knowledgedepartment_department_and_more'),
+ ('channels', '0006_remove_channel_department'),
+ ('identity', '0019_drop_sales_capabilities'),
+ ('notifications', '0008_remove_notification_department'),
+ ('products', '0012_delete_productdepartment'),
+ ('support_portals', '0008_remove_supportportal_department'),
+ ]
+
+ operations = [
+ migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
+ migrations.CreateModel(
+ name='EmployeeGroup',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=120)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='EmployeeGroupMember',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ # Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
+ # state хранит constraint на несуществующее поле и обратная миграция
+ # (reverse DeleteModel в migration-тестах) падает при create_model.
+ migrations.RemoveConstraint(
+ model_name='accessprofile',
+ name='uniq_access_profile_org_name_ci',
+ ),
+ migrations.RemoveConstraint(
+ model_name='accessprofilecapability',
+ name='uniq_access_profile_capability',
+ ),
+ migrations.RemoveConstraint(
+ model_name='department',
+ name='uniq_department_org_code',
+ ),
+ migrations.RemoveConstraint(
+ model_name='employeeaccessassignment',
+ name='access_assignment_scope_department',
+ ),
+ migrations.RemoveConstraint(
+ model_name='employeeaccessassignment',
+ name='uniq_active_org_access_assignment',
+ ),
+ migrations.RemoveConstraint(
+ model_name='employeeaccessassignment',
+ name='uniq_active_dept_access_assignment',
+ ),
+ migrations.RemoveField(
+ model_name='accessprofile',
+ name='organization',
+ ),
+ migrations.RemoveField(
+ model_name='accessprofilecapability',
+ name='access_profile',
+ ),
+ migrations.RemoveField(
+ model_name='employeeaccessassignment',
+ name='access_profile',
+ ),
+ migrations.RemoveField(
+ model_name='accessprofilecapability',
+ name='organization',
+ ),
+ migrations.RemoveField(
+ model_name='department',
+ name='organization',
+ ),
+ # Сначала снимаем constraint, зависящий от primary_department: дроп
+ # колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
+ migrations.RemoveConstraint(
+ model_name='organizationmembership',
+ name='owner_is_company_level',
+ ),
+ migrations.RemoveField(
+ model_name='organizationmembership',
+ name='primary_department',
+ ),
+ migrations.RemoveField(
+ model_name='employeeaccessassignment',
+ name='department',
+ ),
+ migrations.RemoveField(
+ model_name='employeeaccessassignment',
+ name='assigned_by',
+ ),
+ migrations.RemoveField(
+ model_name='employeeaccessassignment',
+ name='employee',
+ ),
+ migrations.RemoveField(
+ model_name='employeeaccessassignment',
+ name='organization',
+ ),
+ migrations.AddField(
+ model_name='employeegroup',
+ name='organization',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
+ ),
+ migrations.AddField(
+ model_name='employeegroupmember',
+ name='employee',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
+ ),
+ migrations.AddField(
+ model_name='employeegroupmember',
+ name='group',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
+ ),
+ migrations.AddField(
+ model_name='employeegroupmember',
+ name='organization',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
+ ),
+ migrations.DeleteModel(
+ name='AccessProfile',
+ ),
+ migrations.DeleteModel(
+ name='AccessProfileCapability',
+ ),
+ migrations.DeleteModel(
+ name='Department',
+ ),
+ migrations.DeleteModel(
+ name='EmployeeAccessAssignment',
+ ),
+ migrations.AddConstraint(
+ model_name='employeegroup',
+ constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
+ ),
+ migrations.AddConstraint(
+ model_name='employeegroupmember',
+ constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
+ ),
+ ]
diff --git a/apps/backend/hub_platform/identity/migrations/0021_humanuser_ui_accent_humanuser_ui_theme.py b/apps/backend/chatballs/identity/migrations/0021_humanuser_ui_accent_humanuser_ui_theme.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0021_humanuser_ui_accent_humanuser_ui_theme.py
rename to apps/backend/chatballs/identity/migrations/0021_humanuser_ui_accent_humanuser_ui_theme.py
diff --git a/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py b/apps/backend/chatballs/identity/migrations/0022_demo_dataset_registry.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py
rename to apps/backend/chatballs/identity/migrations/0022_demo_dataset_registry.py
diff --git a/apps/backend/hub_platform/identity/migrations/0023_humanuser_avatar.py b/apps/backend/chatballs/identity/migrations/0023_humanuser_avatar.py
similarity index 77%
rename from apps/backend/hub_platform/identity/migrations/0023_humanuser_avatar.py
rename to apps/backend/chatballs/identity/migrations/0023_humanuser_avatar.py
index d642f4f..84dc75f 100644
--- a/apps/backend/hub_platform/identity/migrations/0023_humanuser_avatar.py
+++ b/apps/backend/chatballs/identity/migrations/0023_humanuser_avatar.py
@@ -1,6 +1,6 @@
from django.db import migrations, models
-import hub_platform.identity.models
+import chatballs.identity.models
class Migration(migrations.Migration):
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="humanuser",
name="avatar",
- field=models.FileField(blank=True, default="", max_length=512, storage=hub_platform.identity.models.user_storage, upload_to=hub_platform.identity.models.user_avatar_upload_path),
+ field=models.FileField(blank=True, default="", max_length=512, storage=chatballs.identity.models.user_storage, upload_to=chatballs.identity.models.user_avatar_upload_path),
),
migrations.AddField(
model_name="humanuser",
diff --git a/apps/backend/hub_platform/identity/migrations/0024_employeegroup_color.py b/apps/backend/chatballs/identity/migrations/0024_employeegroup_color.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/0024_employeegroup_color.py
rename to apps/backend/chatballs/identity/migrations/0024_employeegroup_color.py
diff --git a/apps/backend/hub_platform/identity/management/commands/__init__.py b/apps/backend/chatballs/identity/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/identity/management/commands/__init__.py
rename to apps/backend/chatballs/identity/migrations/__init__.py
diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/chatballs/identity/models.py
similarity index 97%
rename from apps/backend/hub_platform/identity/models.py
rename to apps/backend/chatballs/identity/models.py
index b8912d6..19704f9 100644
--- a/apps/backend/hub_platform/identity/models.py
+++ b/apps/backend/chatballs/identity/models.py
@@ -10,7 +10,7 @@ from django.db import models
from django.db.models import Q
from django.utils import timezone
-from hub_platform.identity.crypto import EncryptedCharField
+from chatballs.identity.crypto import EncryptedCharField
class HumanUserManager(UserManager):
@@ -247,14 +247,14 @@ class AuditEvent(models.Model):
# Django imports only models.py by convention. Re-export related models after the core
# identity entities are defined so they are registered without growing this file.
-from hub_platform.identity.group_models import ( # noqa: E402, F401
+from chatballs.identity.group_models import ( # noqa: E402, F401
EmployeeGroup,
EmployeeGroupMember,
)
-from hub_platform.identity.invitation_models import ( # noqa: E402, F401
+from chatballs.identity.invitation_models import ( # noqa: E402, F401
OrganizationInvitation,
)
-from hub_platform.identity.demo_models import ( # noqa: E402, F401
+from chatballs.identity.demo_models import ( # noqa: E402, F401
DemoDataset,
DemoRecord,
)
diff --git a/apps/backend/hub_platform/identity/ownership_views.py b/apps/backend/chatballs/identity/ownership_views.py
similarity index 87%
rename from apps/backend/hub_platform/identity/ownership_views.py
rename to apps/backend/chatballs/identity/ownership_views.py
index 9332169..ffcbce8 100644
--- a/apps/backend/hub_platform/identity/ownership_views.py
+++ b/apps/backend/chatballs/identity/ownership_views.py
@@ -4,14 +4,14 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.employee_support import employee_payload
-from hub_platform.identity.employee_validation import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.employee_support import employee_payload
+from chatballs.identity.employee_validation import (
ASSIGNABLE_ROLES,
deny_employee_action,
)
-from hub_platform.identity.governance import EmployeeAction, can_manage_employee
-from hub_platform.identity.models import EmployeeRole, OrganizationMembership
+from chatballs.identity.governance import EmployeeAction, can_manage_employee
+from chatballs.identity.models import EmployeeRole, OrganizationMembership
class OwnershipTransferView(APIView):
diff --git a/apps/backend/hub_platform/identity/password_validation.py b/apps/backend/chatballs/identity/password_validation.py
similarity index 100%
rename from apps/backend/hub_platform/identity/password_validation.py
rename to apps/backend/chatballs/identity/password_validation.py
diff --git a/apps/backend/hub_platform/identity/policy.py b/apps/backend/chatballs/identity/policy.py
similarity index 93%
rename from apps/backend/hub_platform/identity/policy.py
rename to apps/backend/chatballs/identity/policy.py
index 68617ce..f0365c3 100644
--- a/apps/backend/hub_platform/identity/policy.py
+++ b/apps/backend/chatballs/identity/policy.py
@@ -2,12 +2,12 @@ from __future__ import annotations
from dataclasses import dataclass
-from hub_platform.identity.capabilities import (
+from chatballs.identity.capabilities import (
ALL_CAPABILITIES,
EMPLOYEE_CAPABILITIES,
OWNER_ONLY_CAPABILITIES,
)
-from hub_platform.identity.models import EmployeeRole, OrganizationMembership
+from chatballs.identity.models import EmployeeRole, OrganizationMembership
# Ролевая авторизация (SPEC-HUB-0031 §3, ADR-HUB-0043): OWNER и ADMIN идентичны
# (кроме ownership.transfer и невозможности удалить/заблокировать владельца —
@@ -71,7 +71,7 @@ def conversation_visibility(actor) -> dict | None:
return {"none": True}
if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}:
return None
- from hub_platform.identity.group_models import member_group_ids
+ from chatballs.identity.group_models import member_group_ids
return {
"group_ids": member_group_ids(profile),
@@ -83,7 +83,7 @@ def get_effective_access(actor) -> dict[str, object]:
profile = _active_membership(actor)
if profile is None:
return {"capabilities": [], "groups": []}
- from hub_platform.identity.group_models import EmployeeGroupMember
+ from chatballs.identity.group_models import EmployeeGroupMember
groups = [
{"id": link.group_id, "name": link.group.name}
diff --git a/apps/backend/hub_platform/identity/sessions.py b/apps/backend/chatballs/identity/sessions.py
similarity index 100%
rename from apps/backend/hub_platform/identity/sessions.py
rename to apps/backend/chatballs/identity/sessions.py
diff --git a/apps/backend/hub_platform/identity/setup.py b/apps/backend/chatballs/identity/setup.py
similarity index 93%
rename from apps/backend/hub_platform/identity/setup.py
rename to apps/backend/chatballs/identity/setup.py
index 49ab9d6..86c040e 100644
--- a/apps/backend/hub_platform/identity/setup.py
+++ b/apps/backend/chatballs/identity/setup.py
@@ -21,18 +21,18 @@ 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 (
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.identity.audit import record_audit_event
+from chatballs.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
+from chatballs.tenancy.context import TenantActorKind, TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.routing import use_database
ORGANIZATION_NAME_MAX_LENGTH = 255
FULL_NAME_MAX_LENGTH = 255
@@ -155,7 +155,7 @@ def complete_setup(data: SetupInput) -> SetupResult:
)
if clean.install_demo:
# Демо ставит worker по outbox-событию; форма не ждёт.
- from hub_platform.identity.demo_seed.service import request_install
+ from chatballs.identity.demo_seed.service import request_install
request_install(
context=TenantContext.for_resource(
diff --git a/apps/backend/hub_platform/identity/setup_urls.py b/apps/backend/chatballs/identity/setup_urls.py
similarity index 81%
rename from apps/backend/hub_platform/identity/setup_urls.py
rename to apps/backend/chatballs/identity/setup_urls.py
index 8434045..bf10393 100644
--- a/apps/backend/hub_platform/identity/setup_urls.py
+++ b/apps/backend/chatballs/identity/setup_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.identity import setup_views
+from chatballs.identity import setup_views
urlpatterns = [
path("", setup_views.SetupStatusView.as_view(), name="setup-status"),
diff --git a/apps/backend/hub_platform/identity/setup_views.py b/apps/backend/chatballs/identity/setup_views.py
similarity index 96%
rename from apps/backend/hub_platform/identity/setup_views.py
rename to apps/backend/chatballs/identity/setup_views.py
index 4b1d9e8..5e4b85a 100644
--- a/apps/backend/hub_platform/identity/setup_views.py
+++ b/apps/backend/chatballs/identity/setup_views.py
@@ -8,8 +8,8 @@ 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 (
+from chatballs.identity.auth.common import _user_payload
+from chatballs.identity.setup import (
SetupAlreadyCompleted,
SetupInput,
complete_setup,
diff --git a/apps/backend/hub_platform/identity/test_administration_api.py b/apps/backend/chatballs/identity/test_administration_api.py
similarity index 97%
rename from apps/backend/hub_platform/identity/test_administration_api.py
rename to apps/backend/chatballs/identity/test_administration_api.py
index 4231e80..267a003 100644
--- a/apps/backend/hub_platform/identity/test_administration_api.py
+++ b/apps/backend/chatballs/identity/test_administration_api.py
@@ -5,14 +5,14 @@ from tempfile import TemporaryDirectory
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.models import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.testing import TenantAPIClient
+from chatballs.testing import TenantAPIClient
class AdministrationApiTests(TestCase):
@@ -43,7 +43,7 @@ class AdministrationApiTests(TestCase):
self.client = TenantAPIClient()
self.client.force_authenticate(self.owner)
- @override_settings(CUS_DELIVERY_MODE="CLOUD")
+ @override_settings(CHATBALLS_DELIVERY_MODE="CLOUD")
def test_session_exposes_delivery_mode_without_installation_inference(self) -> None:
response = self.client.get("/api/v1/auth/session/")
diff --git a/apps/backend/hub_platform/identity/test_authorization.py b/apps/backend/chatballs/identity/test_authorization.py
similarity index 96%
rename from apps/backend/hub_platform/identity/test_authorization.py
rename to apps/backend/chatballs/identity/test_authorization.py
index b873293..e553a05 100644
--- a/apps/backend/hub_platform/identity/test_authorization.py
+++ b/apps/backend/chatballs/identity/test_authorization.py
@@ -1,13 +1,13 @@
from django.test import TestCase
-from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember
-from hub_platform.identity.models import (
+from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.identity.policy import (
+from chatballs.identity.policy import (
ResourceScope,
authorize,
conversation_visibility,
diff --git a/apps/backend/hub_platform/identity/test_invitation_accept.py b/apps/backend/chatballs/identity/test_invitation_accept.py
similarity index 96%
rename from apps/backend/hub_platform/identity/test_invitation_accept.py
rename to apps/backend/chatballs/identity/test_invitation_accept.py
index 946775d..4b09371 100644
--- a/apps/backend/hub_platform/identity/test_invitation_accept.py
+++ b/apps/backend/chatballs/identity/test_invitation_accept.py
@@ -5,12 +5,12 @@ from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
-from hub_platform.identity.invitation_service import (
+from chatballs.identity.invitation_service import (
InvitationError,
accept_invitation,
issue_invitation,
)
-from hub_platform.identity.models import (
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
diff --git a/apps/backend/hub_platform/identity/test_membership_identity.py b/apps/backend/chatballs/identity/test_membership_identity.py
similarity index 97%
rename from apps/backend/hub_platform/identity/test_membership_identity.py
rename to apps/backend/chatballs/identity/test_membership_identity.py
index 3af5fdd..efe15a7 100644
--- a/apps/backend/hub_platform/identity/test_membership_identity.py
+++ b/apps/backend/chatballs/identity/test_membership_identity.py
@@ -6,18 +6,18 @@ from django.db import IntegrityError, transaction
from django.test import TestCase
from django.utils import timezone
-from hub_platform.identity.invitation_service import (
+from chatballs.identity.invitation_service import (
issue_invitation,
pending_invitation_for_token,
)
-from hub_platform.identity.models import (
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationInvitation,
OrganizationMembership,
)
-from hub_platform.identity.policy import ResourceScope, authorize
+from chatballs.identity.policy import ResourceScope, authorize
class MembershipIdentityTests(TestCase):
diff --git a/apps/backend/hub_platform/identity/test_membership_migration.py b/apps/backend/chatballs/identity/test_membership_migration.py
similarity index 100%
rename from apps/backend/hub_platform/identity/test_membership_migration.py
rename to apps/backend/chatballs/identity/test_membership_migration.py
diff --git a/apps/backend/hub_platform/identity/test_seed_demo.py b/apps/backend/chatballs/identity/test_seed_demo.py
similarity index 93%
rename from apps/backend/hub_platform/identity/test_seed_demo.py
rename to apps/backend/chatballs/identity/test_seed_demo.py
index eb2a0df..8ba4acf 100644
--- a/apps/backend/hub_platform/identity/test_seed_demo.py
+++ b/apps/backend/chatballs/identity/test_seed_demo.py
@@ -8,25 +8,25 @@ 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 chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment
-from hub_platform.conversations.models import (
+from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment
+from chatballs.conversations.models import (
Contact,
Conversation,
LifecycleState,
Message,
MessageKind,
)
-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.group_models import EmployeeGroup
-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
+from chatballs.events.handlers import dispatch
+from chatballs.events.models import OutboxEvent
+from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus, DemoRecord
+from chatballs.identity.demo_seed import service
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
+from chatballs.identity.setup import SetupInput, complete_setup
+from chatballs.tenancy.context import TenantActorKind, TenantContext
+from chatballs.tenancy.database import tenant_atomic
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-demo-media-")
@@ -51,7 +51,7 @@ 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."):
+ if not model.__module__.startswith("chatballs."):
continue
if label in COVERAGE_EXEMPT_APPS or (label, model._meta.model_name) in COVERAGE_EXEMPT:
continue
diff --git a/apps/backend/hub_platform/identity/test_setup.py b/apps/backend/chatballs/identity/test_setup.py
similarity index 96%
rename from apps/backend/hub_platform/identity/test_setup.py
rename to apps/backend/chatballs/identity/test_setup.py
index 089086f..61bbea5 100644
--- a/apps/backend/hub_platform/identity/test_setup.py
+++ b/apps/backend/chatballs/identity/test_setup.py
@@ -1,10 +1,10 @@
import json
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.ai.models import KnowledgeCategory
-from hub_platform.identity.models import (
+from chatballs.ai.models import KnowledgeCategory
+from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/chatballs/identity/tests.py
similarity index 98%
rename from apps/backend/hub_platform/identity/tests.py
rename to apps/backend/chatballs/identity/tests.py
index 8a2f2de..1cac0ee 100644
--- a/apps/backend/hub_platform/identity/tests.py
+++ b/apps/backend/chatballs/identity/tests.py
@@ -8,12 +8,12 @@ from django.core import mail
from django.test import Client, TestCase, override_settings
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
from rest_framework.throttling import ScopedRateThrottle
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.auth.totp_utils import _totp_code
-from hub_platform.identity.models import (
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.auth.totp_utils import _totp_code
+from chatballs.identity.models import (
AuditEvent,
EmployeeGroup,
EmployeeRole,
@@ -21,8 +21,8 @@ from hub_platform.identity.models import (
Organization,
OrganizationMembership,
)
-from hub_platform.identity.policy import ResourceScope, authorize
-from hub_platform.products.models import Product
+from chatballs.identity.policy import ResourceScope, authorize
+from chatballs.products.models import Product
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@@ -156,7 +156,7 @@ class AuthEndpointTests(TestCase):
self.assertTrue(AuditEvent.objects.filter(action="identity.login_failed").exists())
def test_password_reset_request_enqueues_outbox_event(self) -> None:
- from hub_platform.events.models import OutboxEvent
+ from chatballs.events.models import OutboxEvent
owner = HumanUser.objects.get(email="owner@edevs.tech")
response = self.client.post(
@@ -174,7 +174,7 @@ class AuthEndpointTests(TestCase):
self.assertTrue(AuditEvent.objects.filter(action="identity.password_reset_requested").exists())
def test_password_reset_request_does_not_reveal_unknown_email(self) -> None:
- from hub_platform.events.models import OutboxEvent
+ from chatballs.events.models import OutboxEvent
response = self.client.post(
"/api/v1/auth/password-reset/request/",
@@ -188,7 +188,7 @@ class AuthEndpointTests(TestCase):
self.assertFalse(OutboxEvent.objects.filter(event_type="identity.password_reset_requested").exists())
def test_password_reset_handler_sends_email(self) -> None:
- from hub_platform.identity.event_handlers import handle_password_reset_requested
+ from chatballs.identity.event_handlers import handle_password_reset_requested
owner = HumanUser.objects.get(email="owner@edevs.tech")
handle_password_reset_requested({"userId": owner.id}, None)
@@ -434,7 +434,7 @@ class AuthEndpointTests(TestCase):
self.assertTrue(verify_response.json()["authenticated"])
-@override_settings(ROOT_URLCONF="hub_backend.urls_admin")
+@override_settings(ROOT_URLCONF="chatballs_backend.urls_admin")
class DjangoAdminTests(TestCase):
def test_bootstrapped_owner_can_access_django_admin(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
@@ -628,7 +628,7 @@ class TotpSecretEncryptionTests(TestCase):
def test_totp_secret_is_encrypted_at_rest_and_decrypted_on_load(self) -> None:
from django.db import connection
- from hub_platform.identity.crypto import decrypt_secret
+ from chatballs.identity.crypto import decrypt_secret
owner = HumanUser.objects.get(email="owner@edevs.tech")
owner.totp_secret = "JBSWY3DPEHPK3PXP"
diff --git a/apps/backend/hub_platform/integrations/__init__.py b/apps/backend/chatballs/integrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/__init__.py
rename to apps/backend/chatballs/integrations/__init__.py
diff --git a/apps/backend/chatballs/integrations/admin.py b/apps/backend/chatballs/integrations/admin.py
new file mode 100644
index 0000000..f00b223
--- /dev/null
+++ b/apps/backend/chatballs/integrations/admin.py
@@ -0,0 +1,11 @@
+from django.contrib import admin
+
+from chatballs.integrations.models import Integration
+
+
+@admin.register(Integration)
+class IntegrationAdmin(admin.ModelAdmin):
+ list_display = ("name", "provider", "kind", "status", "last_checked_at")
+ list_filter = ("provider", "kind", "status")
+ search_fields = ("name",)
+ readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
diff --git a/apps/backend/chatballs/integrations/apps.py b/apps/backend/chatballs/integrations/apps.py
new file mode 100644
index 0000000..b19ef09
--- /dev/null
+++ b/apps/backend/chatballs/integrations/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class IntegrationsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ label = "integrations"
+ name = "chatballs.integrations"
+ verbose_name = "Integrations: providers and connections"
diff --git a/apps/backend/hub_platform/integrations/checks.py b/apps/backend/chatballs/integrations/checks.py
similarity index 97%
rename from apps/backend/hub_platform/integrations/checks.py
rename to apps/backend/chatballs/integrations/checks.py
index 4db763c..1cff6d4 100644
--- a/apps/backend/hub_platform/integrations/checks.py
+++ b/apps/backend/chatballs/integrations/checks.py
@@ -22,7 +22,7 @@ import urllib.request
from django.conf import settings
-from hub_platform.integrations.proxy import build_opener
+from chatballs.integrations.proxy import build_opener
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
@@ -36,7 +36,7 @@ CheckResult = tuple[bool, str, dict]
def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = "") -> tuple[int, dict]:
opener = build_opener(proxy_url)
request = urllib.request.Request(url, headers=headers or {}, method="GET")
- with opener.open(request, timeout=settings.CUS_AI_REQUEST_TIMEOUT) as response:
+ with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
body = response.read().decode("utf-8")
try:
data = json.loads(body) if body else {}
@@ -132,7 +132,7 @@ def check_email(*, secret: str, config: dict) -> CheckResult:
return False, "Не указан пароль ящика", {}
if not address or not imap_host or not smtp_host:
return False, "Не заполнены адрес, IMAP- или SMTP-хост", {}
- timeout = settings.CUS_AI_REQUEST_TIMEOUT
+ timeout = settings.CHATBALLS_AI_REQUEST_TIMEOUT
try:
imap_port = int(config.get("imap_port") or 993)
diff --git a/apps/backend/chatballs/integrations/migrations/0001_initial.py b/apps/backend/chatballs/integrations/migrations/0001_initial.py
new file mode 100644
index 0000000..9237772
--- /dev/null
+++ b/apps/backend/chatballs/integrations/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.2.15 on 2026-06-27 21:12
+
+import django.db.models.deletion
+import chatballs.identity.crypto
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('identity', '0006_alter_employeeprofile_totp_secret'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Integration',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
+ ('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
+ ('name', models.CharField(max_length=255)),
+ ('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
+ ('config', models.JSONField(blank=True, default=dict)),
+ ('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
+ ('last_checked_at', models.DateTimeField(blank=True, null=True)),
+ ('last_error', models.TextField(blank=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
+ ],
+ options={
+ 'ordering': ['provider', 'name'],
+ 'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
+ },
+ ),
+ ]
diff --git a/apps/backend/hub_platform/integrations/migrations/0002_integration_channel_integration_poll_marker.py b/apps/backend/chatballs/integrations/migrations/0002_integration_channel_integration_poll_marker.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/migrations/0002_integration_channel_integration_poll_marker.py
rename to apps/backend/chatballs/integrations/migrations/0002_integration_channel_integration_poll_marker.py
diff --git a/apps/backend/hub_platform/integrations/migrations/0003_alter_integration_provider.py b/apps/backend/chatballs/integrations/migrations/0003_alter_integration_provider.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/migrations/0003_alter_integration_provider.py
rename to apps/backend/chatballs/integrations/migrations/0003_alter_integration_provider.py
diff --git a/apps/backend/hub_platform/integrations/migrations/0004_alter_integration_provider_email.py b/apps/backend/chatballs/integrations/migrations/0004_alter_integration_provider_email.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/migrations/0004_alter_integration_provider_email.py
rename to apps/backend/chatballs/integrations/migrations/0004_alter_integration_provider_email.py
diff --git a/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py b/apps/backend/chatballs/integrations/migrations/0005_integration_is_active.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py
rename to apps/backend/chatballs/integrations/migrations/0005_integration_is_active.py
diff --git a/apps/backend/hub_platform/integrations/migrations/0006_integration_demo_provider.py b/apps/backend/chatballs/integrations/migrations/0006_integration_demo_provider.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/migrations/0006_integration_demo_provider.py
rename to apps/backend/chatballs/integrations/migrations/0006_integration_demo_provider.py
diff --git a/apps/backend/hub_platform/integrations/migrations/__init__.py b/apps/backend/chatballs/integrations/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/migrations/__init__.py
rename to apps/backend/chatballs/integrations/migrations/__init__.py
diff --git a/apps/backend/hub_platform/integrations/models.py b/apps/backend/chatballs/integrations/models.py
similarity index 98%
rename from apps/backend/hub_platform/integrations/models.py
rename to apps/backend/chatballs/integrations/models.py
index e7abb3a..3322f5f 100644
--- a/apps/backend/hub_platform/integrations/models.py
+++ b/apps/backend/chatballs/integrations/models.py
@@ -1,6 +1,6 @@
from django.db import models
-from hub_platform.identity.crypto import EncryptedCharField
+from chatballs.identity.crypto import EncryptedCharField
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-HUB-0020.
# Привязка подключения к каналу обработки появляется в M1 (ADR-HUB-0019).
diff --git a/apps/backend/hub_platform/integrations/proxy.py b/apps/backend/chatballs/integrations/proxy.py
similarity index 100%
rename from apps/backend/hub_platform/integrations/proxy.py
rename to apps/backend/chatballs/integrations/proxy.py
diff --git a/apps/backend/hub_platform/integrations/selectors.py b/apps/backend/chatballs/integrations/selectors.py
similarity index 83%
rename from apps/backend/hub_platform/integrations/selectors.py
rename to apps/backend/chatballs/integrations/selectors.py
index fcba009..f2b8f74 100644
--- a/apps/backend/hub_platform/integrations/selectors.py
+++ b/apps/backend/chatballs/integrations/selectors.py
@@ -1,7 +1,7 @@
from django.db.models import QuerySet
-from hub_platform.integrations.models import Integration
-from hub_platform.tenancy.context import TenantContext
+from chatballs.integrations.models import Integration
+from chatballs.tenancy.context import TenantContext
def integrations_for_context(context: TenantContext) -> QuerySet[Integration]:
diff --git a/apps/backend/hub_platform/integrations/serializers.py b/apps/backend/chatballs/integrations/serializers.py
similarity index 94%
rename from apps/backend/hub_platform/integrations/serializers.py
rename to apps/backend/chatballs/integrations/serializers.py
index 4fbb458..31fc111 100644
--- a/apps/backend/hub_platform/integrations/serializers.py
+++ b/apps/backend/chatballs/integrations/serializers.py
@@ -1,4 +1,4 @@
-from hub_platform.integrations.models import Integration
+from chatballs.integrations.models import Integration
def integration_payload(integration: Integration) -> dict[str, object]:
@@ -42,7 +42,7 @@ def integration_payload(integration: Integration) -> dict[str, object]:
"updatedAt": integration.updated_at.isoformat(),
}
if integration.provider == "WEB":
- from hub_platform.webchat.widgets import widget_for_integration, widget_payload
+ from chatballs.webchat.widgets import widget_for_integration, widget_payload
payload["webChatWidget"] = widget_payload(widget_for_integration(integration))
return payload
diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/chatballs/integrations/services.py
similarity index 96%
rename from apps/backend/hub_platform/integrations/services.py
rename to apps/backend/chatballs/integrations/services.py
index 3e4c7b3..373faf7 100644
--- a/apps/backend/hub_platform/integrations/services.py
+++ b/apps/backend/chatballs/integrations/services.py
@@ -4,15 +4,15 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
-from hub_platform.identity.models import Organization
-from hub_platform.integrations import checks
-from hub_platform.integrations.models import (
+from chatballs.identity.models import Organization
+from chatballs.integrations import checks
+from chatballs.integrations.models import (
PROVIDER_KIND,
Integration,
IntegrationProvider,
IntegrationStatus,
)
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -33,7 +33,7 @@ def _resolve_channel(
):
if not channel_id:
return None
- from hub_platform.channels.models import Channel
+ from chatballs.channels.models import Channel
try:
channel = Channel.objects.get(organization=organization, id=channel_id)
@@ -131,7 +131,7 @@ def _publish_web_widget(*, context: TenantContext, integration: Integration) ->
после сохранения. Иначе любая правка подключения оставляла бы виджет в DRAFT, а
подключение в UNCHECKED; раздача требует PUBLISHED + OK (webchat.views), и чат на
сайте молча падал бы в «Чат временно недоступен» до ручного «Проверить»."""
- from hub_platform.webchat.widgets import ensure_widget
+ from chatballs.webchat.widgets import ensure_widget
# Отдельным вызовом — чтобы конфликт смены режима остался 400 на сохранении;
# внутри проверки та же ошибка превратилась бы в статус ERROR.
@@ -223,7 +223,7 @@ def _check_web(context: TenantContext, integration: Integration) -> tuple[bool,
Проверяем конфигурацию конкретного widget entry point."""
if integration.channel_id is None:
return False, "Подключение не привязано к каналу — виджет не активен", {}
- from hub_platform.webchat.widgets import ensure_widget
+ from chatballs.webchat.widgets import ensure_widget
try:
widget = ensure_widget(integration)
@@ -268,7 +268,7 @@ def test_integration(*, context: TenantContext, integration: Integration) -> Int
update_fields.append("config")
integration.save(update_fields=update_fields)
if integration.provider == IntegrationProvider.WEB:
- from hub_platform.webchat.widgets import sync_widget_check_status
+ from chatballs.webchat.widgets import sync_widget_check_status
sync_widget_check_status(integration, ok=ok)
return integration
diff --git a/apps/backend/hub_platform/integrations/test_activation.py b/apps/backend/chatballs/integrations/test_activation.py
similarity index 82%
rename from apps/backend/hub_platform/integrations/test_activation.py
rename to apps/backend/chatballs/integrations/test_activation.py
index b093251..abd9940 100644
--- a/apps/backend/hub_platform/integrations/test_activation.py
+++ b/apps/backend/chatballs/integrations/test_activation.py
@@ -1,17 +1,17 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.integrations.models import IntegrationProvider
-from hub_platform.integrations.serializers import integration_payload
-from hub_platform.integrations.services import (
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations.models import IntegrationProvider
+from chatballs.integrations.serializers import integration_payload
+from chatballs.integrations.services import (
IntegrationInput,
create_integration,
update_integration,
)
-from hub_platform.testing import system_tenant_context
+from chatballs.testing import system_tenant_context
class IntegrationActivationTests(TestCase):
diff --git a/apps/backend/chatballs/integrations/test_email.py b/apps/backend/chatballs/integrations/test_email.py
new file mode 100644
index 0000000..06d0192
--- /dev/null
+++ b/apps/backend/chatballs/integrations/test_email.py
@@ -0,0 +1,137 @@
+"""Email-подключение: конфигурация, проверка и сериализация (SPEC-HUB-0025 §5)."""
+
+from unittest import mock
+
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations import checks
+from chatballs.integrations.models import IntegrationKind, IntegrationProvider, IntegrationStatus
+from chatballs.integrations.serializers import integration_payload
+from chatballs.integrations.services import IntegrationInput, create_integration
+from chatballs.testing import system_tenant_context
+
+EMAIL_INPUT = {
+ "email": "Support@edevs.tech",
+ "imapHost": "imap.yandex.ru",
+ "smtpHost": "smtp.yandex.ru",
+}
+
+
+class EmailConfigTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.context = system_tenant_context(Organization.objects.get(slug="edevs"))
+
+ def _create(self, *, secret="app-password", config=None):
+ return create_integration(
+ context=self.context,
+ data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret=secret, config=config or dict(EMAIL_INPUT)),
+ )
+
+ def test_email_is_messenger_kind_with_normalized_config(self) -> None:
+ integration = self._create()
+ self.assertEqual(integration.kind, IntegrationKind.MESSENGER)
+ # camelCase → snake_case, адрес приводится к lowercase, порты/SSL по умолчанию.
+ self.assertEqual(
+ integration.config,
+ {
+ "email": "support@edevs.tech",
+ "imap_host": "imap.yandex.ru",
+ "imap_port": 993,
+ "imap_ssl": True,
+ "smtp_host": "smtp.yandex.ru",
+ "smtp_port": 465,
+ "smtp_ssl": True,
+ },
+ )
+
+ def test_secret_required_on_create(self) -> None:
+ with self.assertRaises(ValidationError):
+ self._create(secret="")
+
+ def test_hosts_and_address_required(self) -> None:
+ with self.assertRaises(ValidationError):
+ self._create(config={"email": "a@b.c", "imapHost": "imap.b.c"})
+
+ def test_notifications_purpose_rejected(self) -> None:
+ # Email не может быть сервисным ботом уведомлений (ADR-HUB-0035, границы).
+ with self.assertRaises(ValidationError):
+ self._create(config={**EMAIL_INPUT, "purpose": "notifications"})
+
+ def test_serializer_exposes_email_config(self) -> None:
+ payload = integration_payload(self._create())
+ config = payload["config"]
+ self.assertEqual(config["email"], "support@edevs.tech")
+ self.assertEqual(config["imapHost"], "imap.yandex.ru")
+ self.assertEqual(config["imapPort"], 993)
+ self.assertTrue(config["smtpSsl"])
+ self.assertTrue(payload["hasSecret"])
+
+
+class EmailCheckTests(TestCase):
+ """check_email: успех только когда успешны ОБЕ стороны (IMAP и SMTP)."""
+
+ CONFIG = {
+ "email": "support@edevs.tech",
+ "imap_host": "imap.test",
+ "imap_port": 993,
+ "imap_ssl": True,
+ "smtp_host": "smtp.test",
+ "smtp_port": 465,
+ "smtp_ssl": True,
+ }
+
+ def test_both_sides_ok(self) -> None:
+ with (
+ mock.patch.object(checks.imaplib, "IMAP4_SSL") as imap_cls,
+ mock.patch.object(checks.smtplib, "SMTP_SSL") as smtp_cls,
+ ):
+ ok, detail, meta = checks.check_email(secret="pw", config=self.CONFIG)
+ self.assertTrue(ok)
+ self.assertEqual(detail, "Email: support@edevs.tech")
+ imap_cls.return_value.login.assert_called_once_with("support@edevs.tech", "pw")
+ smtp_cls.return_value.login.assert_called_once_with("support@edevs.tech", "pw")
+
+ def test_imap_failure_reported(self) -> None:
+ with mock.patch.object(checks.imaplib, "IMAP4_SSL", side_effect=OSError("connection refused")):
+ ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)
+ self.assertFalse(ok)
+ self.assertIn("IMAP:", detail)
+
+ def test_smtp_failure_reported(self) -> None:
+ with (
+ mock.patch.object(checks.imaplib, "IMAP4_SSL"),
+ mock.patch.object(checks.smtplib, "SMTP_SSL", side_effect=OSError("connection refused")),
+ ):
+ ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)
+ self.assertFalse(ok)
+ self.assertIn("SMTP:", detail)
+
+ def test_missing_secret_fails(self) -> None:
+ ok, detail, _ = checks.check_email(secret="", config=self.CONFIG)
+ self.assertFalse(ok)
+ self.assertIn("пароль", detail)
+
+
+class EmailTestIntegrationDispatchTests(TestCase):
+ """test_integration диспетчеризует EMAIL на check_email (полный config)."""
+
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.context = system_tenant_context(Organization.objects.get(slug="edevs"))
+
+ def test_email_check_updates_status(self) -> None:
+ from chatballs.integrations.services import test_integration as run_integration_test
+
+ integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret="pw", config=dict(EMAIL_INPUT)),
+ )
+ with mock.patch.object(checks, "check_email", return_value=(True, "Email: support@edevs.tech", {})) as check:
+ checked = run_integration_test(context=self.context, integration=integration)
+ check.assert_called_once()
+ self.assertEqual(checked.status, IntegrationStatus.OK)
+ self.assertEqual(checked.last_error, "")
diff --git a/apps/backend/chatballs/integrations/tests.py b/apps/backend/chatballs/integrations/tests.py
new file mode 100644
index 0000000..bd82330
--- /dev/null
+++ b/apps/backend/chatballs/integrations/tests.py
@@ -0,0 +1,357 @@
+import json
+import urllib.request
+from unittest import mock
+
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations import checks
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
+from chatballs.integrations.serializers import integration_payload
+from chatballs.integrations.services import (
+ IntegrationInput,
+ create_integration,
+ test_integration as run_integration_test,
+ update_integration,
+)
+from chatballs.testing import system_tenant_context
+
+
+def _fake_response(status: int, body: dict):
+ response = mock.MagicMock()
+ response.status = status
+ response.read.return_value = json.dumps(body).encode("utf-8")
+ return response
+
+
+class WebIntegrationCheckTests(TestCase):
+ """«Проверить» для Web-виджета: внешнего API нет — валидируем привязку к
+ каналу и самостоятельный widget entry point подключения."""
+
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.organization = Organization.objects.get(slug="edevs")
+ self.context = system_tenant_context(self.organization)
+ from chatballs.channels.models import Channel
+
+ self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
+
+ def _web(self, name: str, channel=None, origins=("edevs.tech",)) -> Integration:
+ return create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.WEB,
+ name=name,
+ channel_id=channel.id if channel else None,
+ config={"allowedOrigins": list(origins)},
+ ),
+ )
+
+ def test_web_without_channel_fails(self) -> None:
+ integration = run_integration_test(
+ context=self.context, integration=self._web("Виджет", channel=None)
+ )
+ self.assertEqual(integration.status, IntegrationStatus.ERROR)
+ self.assertIn("не привязано к каналу", integration.last_error)
+
+ def test_web_bound_to_channel_is_ok(self) -> None:
+ integration = run_integration_test(
+ context=self.context, integration=self._web("Виджет", channel=self.channel)
+ )
+ self.assertEqual(integration.status, IntegrationStatus.OK)
+ self.assertEqual(integration.last_error, "")
+ self.assertEqual(integration.web_chat_widget.status, "PUBLISHED")
+ self.assertTrue(integration_payload(integration)["webChatWidget"]["publicKey"])
+
+ def test_two_web_connections_on_one_channel_are_independent(self) -> None:
+ first = run_integration_test(
+ context=self.context,
+ integration=self._web("A-виджет", channel=self.channel),
+ )
+ second = run_integration_test(
+ context=self.context, integration=self._web("B-виджет", channel=self.channel)
+ )
+ self.assertEqual(first.status, IntegrationStatus.OK)
+ self.assertEqual(second.status, IntegrationStatus.OK)
+ self.assertNotEqual(
+ first.web_chat_widget.public_key,
+ second.web_chat_widget.public_key,
+ )
+
+ def test_web_without_allowed_origins_fails(self) -> None:
+ """Пустой allowed_origins в проде запрещает все домены, и виджет молча
+ показывает «Чат временно недоступен» — проверка обязана это ловить."""
+ integration = run_integration_test(
+ context=self.context,
+ integration=self._web("Виджет", channel=self.channel, origins=()),
+ )
+ self.assertEqual(integration.status, IntegrationStatus.ERROR)
+ self.assertIn("Не заданы разрешённые домены", integration.last_error)
+ self.assertEqual(integration.web_chat_widget.status, "DRAFT")
+
+ def test_allowed_origins_reach_the_widget(self) -> None:
+ integration = run_integration_test(
+ context=self.context,
+ integration=self._web(
+ "Виджет", channel=self.channel, origins=("edevs.tech", "*.edevs.tech")
+ ),
+ )
+ self.assertEqual(integration.status, IntegrationStatus.OK)
+ self.assertEqual(
+ integration.web_chat_widget.allowed_origins, ["edevs.tech", "*.edevs.tech"]
+ )
+ self.assertEqual(
+ integration_payload(integration)["config"]["allowedOrigins"],
+ ["edevs.tech", "*.edevs.tech"],
+ )
+
+ def test_saving_connection_keeps_the_widget_published(self) -> None:
+ """Регрессия: раздача виджета требует PUBLISHED + OK (webchat.views), а правка
+ подключения не должна ни ронять чат на сайте до ручного «Проверить», ни
+ обнулять домены — именно так виджеты уходили в «Чат временно недоступен»."""
+ integration = self._web("Виджет", channel=self.channel)
+ self.assertEqual(integration.status, IntegrationStatus.OK)
+ self.assertEqual(integration.web_chat_widget.status, "PUBLISHED")
+
+ renamed = update_integration(
+ context=self.context,
+ integration=integration,
+ data=IntegrationInput(
+ provider=IntegrationProvider.WEB,
+ name="Виджет · переименован",
+ channel_id=self.channel.id,
+ config={"allowedOrigins": ["edevs.tech"]},
+ ),
+ )
+ self.assertEqual(renamed.status, IntegrationStatus.OK)
+ self.assertEqual(renamed.web_chat_widget.status, "PUBLISHED")
+ self.assertEqual(renamed.config["allowed_domains"], ["edevs.tech"])
+ self.assertEqual(renamed.web_chat_widget.allowed_origins, ["edevs.tech"])
+
+class ProxyConfigTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.organization = Organization.objects.get(slug="edevs")
+ self.context = system_tenant_context(self.organization)
+
+ def test_proxy_url_is_persisted_in_config(self) -> None:
+ integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.OPENROUTER,
+ name="OpenRouter",
+ secret="sk-test",
+ config={"baseUrl": "https://openrouter.ai/api/v1", "proxyUrl": "http://user:pass@host:8080"},
+ ),
+ )
+ self.assertEqual(integration.config["proxy_url"], "http://user:pass@host:8080")
+
+ def test_update_clears_proxy_when_empty(self) -> None:
+ integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.OPENROUTER,
+ name="OpenRouter",
+ secret="sk-test",
+ config={"proxyUrl": "http://host:8080"},
+ ),
+ )
+ updated = update_integration(
+ context=self.context,
+ integration=integration,
+ data=IntegrationInput(provider=IntegrationProvider.OPENROUTER, name="OpenRouter", config={"proxyUrl": ""}),
+ )
+ self.assertNotIn("proxy_url", updated.config)
+
+ def test_serializer_exposes_proxy_url(self) -> None:
+ integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.OPENROUTER,
+ name="OpenRouter",
+ secret="sk-test",
+ config={"proxyUrl": "http://host:8080"},
+ ),
+ )
+ self.assertEqual(integration_payload(integration)["config"]["proxyUrl"], "http://host:8080")
+
+
+def _patched_opener(captured: dict, body: dict):
+ def fake_build_opener(proxy_url):
+ captured["proxy_url"] = proxy_url
+ opener = mock.MagicMock()
+ ctx = mock.MagicMock()
+ ctx.__enter__.return_value = _fake_response(200, body)
+ opener.open.return_value = ctx
+ return opener
+
+ return fake_build_opener
+
+
+class CheckProxyTransportTests(TestCase):
+ """check_* должны прокидывать proxy_url в единый opener (build_opener)."""
+
+ def test_check_openrouter_uses_proxy(self) -> None:
+ captured = {}
+ with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})):
+ ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="http://proxy:8080")
+
+ self.assertTrue(ok)
+ self.assertEqual(captured["proxy_url"], "http://proxy:8080")
+
+ def test_check_openrouter_without_proxy_passes_empty(self) -> None:
+ captured = {}
+ with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})):
+ checks.check_openrouter(secret="sk-test", base_url="", proxy_url="")
+
+ self.assertEqual(captured["proxy_url"], "")
+
+ def test_check_openrouter_supports_socks_url(self) -> None:
+ captured = {}
+ with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})):
+ ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="socks5://proxy:1080")
+
+ self.assertTrue(ok)
+ self.assertEqual(captured["proxy_url"], "socks5://proxy:1080")
+
+
+class OpenRouterProviderProxyTests(TestCase):
+ def test_provider_routes_through_proxy_handler(self) -> None:
+ from chatballs.ai.provider.openrouter import OpenRouterProvider
+
+ captured = {}
+ provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080")
+ # После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7):
+ # мокаем именно его build_opener.
+ with mock.patch("chatballs.ai.provider.openai_http.build_opener", side_effect=_patched_opener(captured, {"choices": [{"message": {"content": "ok"}}], "model": "x"})):
+ result = provider.chat(messages=[], model="x")
+
+ self.assertEqual(result.text, "ok")
+ self.assertEqual(captured["proxy_url"], "http://proxy:8080")
+
+
+class BuildOpenerSocksTests(TestCase):
+ def test_http_scheme_uses_proxy_handler(self) -> None:
+ from chatballs.integrations.proxy import build_opener
+
+ opener = build_opener("http://proxy:8080")
+ self.assertTrue(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers))
+
+ def test_socks5_scheme_builds_socks_handler(self) -> None:
+ from chatballs.integrations.proxy import build_opener
+
+ opener = build_opener("socks5://user:pass@host:1080")
+ # PySocks установлен → handler строится без ошибок и не является ProxyHandler.
+ self.assertFalse(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers))
+
+ def test_socks_without_pysocks_raises_value_error(self) -> None:
+ from chatballs.integrations import proxy
+ from chatballs.integrations.proxy import build_opener
+
+ with mock.patch.object(proxy, "_import_socks", side_effect=ValueError("no PySocks")):
+ with self.assertRaises(ValueError):
+ build_opener("socks5://host:1080")
+
+
+class CustomIntegrationTests(TestCase):
+ """Generic OpenAI-compatible BYOK provider (ADR-HUB-0034, SPEC-HUB-0024 §5).
+
+ Covers the three required fields (endpoint, API key, model), runtime
+ reading of the model field (SPEC #2, #5 — closing the as-built gap where
+ «Модель по умолчанию» was decorative), and the connectivity check against
+ an arbitrary OpenAI-compatible endpoint.
+ """
+
+ def setUp(self) -> None:
+ bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
+ self.organization = Organization.objects.get(slug="edevs")
+ self.context = system_tenant_context(self.organization)
+
+ def test_custom_persists_endpoint_and_model(self) -> None:
+ integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.CUSTOM,
+ name="Мой провайдер",
+ secret="sk-custom",
+ config={
+ "baseUrl": "https://api.example.com/v1",
+ "defaultModel": "local-llama-3",
+ },
+ ),
+ )
+ # Модель — отдельное рабочее поле (ADR-HUB-0034 §4), читается в рантайме.
+ self.assertEqual(integration.config["base_url"], "https://api.example.com/v1")
+ self.assertEqual(integration.config["default_model"], "local-llama-3")
+ self.assertEqual(integration.kind, IntegrationKind.LLM_PROVIDER)
+
+ def test_custom_requires_model_free_text(self) -> None:
+ with self.assertRaises(ValidationError):
+ create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.CUSTOM,
+ name="Мой провайдер",
+ secret="sk-custom",
+ config={"baseUrl": "https://api.example.com/v1"},
+ ),
+ )
+
+ def test_custom_requires_base_url_and_api_key(self) -> None:
+ with self.assertRaises(ValidationError):
+ create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.CUSTOM,
+ name="Без endpoint",
+ secret="sk-custom",
+ config={"defaultModel": "local-model"},
+ ),
+ )
+ with self.assertRaises(ValidationError):
+ create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.CUSTOM,
+ name="Без ключа",
+ secret="",
+ config={
+ "baseUrl": "https://api.example.com/v1",
+ "defaultModel": "local-model",
+ },
+ ),
+ )
+
+ def test_check_custom_lists_models_on_openai_shape(self) -> None:
+ captured = {}
+ with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": [{"id": "local-llama-3"}]})):
+ ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1")
+
+ self.assertTrue(ok)
+ self.assertIn("1 моделей", detail)
+
+ def test_check_custom_without_secret_fails(self) -> None:
+ ok, detail, meta = checks.check_custom(secret="", base_url="https://api.example.com/v1")
+ self.assertFalse(ok)
+ self.assertEqual(detail, "Не указан API-ключ")
+
+ def test_check_custom_without_base_url_fails(self) -> None:
+ ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="")
+ self.assertFalse(ok)
+ self.assertEqual(detail, "Не указан Base URL")
+
+ def test_check_custom_non_200_is_error(self) -> None:
+ response = mock.MagicMock()
+ response.status = 401
+ response.read.return_value = b"{}"
+ opener = mock.MagicMock()
+ ctx = mock.MagicMock()
+ ctx.__enter__.return_value = response
+ opener.open.return_value = ctx
+ with mock.patch("chatballs.integrations.checks.build_opener", return_value=opener):
+ ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1")
+
+ self.assertFalse(ok)
+ self.assertIn("401", detail)
diff --git a/apps/backend/chatballs/integrations/urls.py b/apps/backend/chatballs/integrations/urls.py
new file mode 100644
index 0000000..17cb7ac
--- /dev/null
+++ b/apps/backend/chatballs/integrations/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from chatballs.integrations import views
+
+urlpatterns = [
+ path("", views.IntegrationListView.as_view(), name="integration-list"),
+ path("/", views.IntegrationDetailView.as_view(), name="integration-detail"),
+ path("/test/", views.IntegrationTestView.as_view(), name="integration-test"),
+]
diff --git a/apps/backend/hub_platform/integrations/views.py b/apps/backend/chatballs/integrations/views.py
similarity index 93%
rename from apps/backend/hub_platform/integrations/views.py
rename to apps/backend/chatballs/integrations/views.py
index 2eaaf76..ff8992d 100644
--- a/apps/backend/hub_platform/integrations/views.py
+++ b/apps/backend/chatballs/integrations/views.py
@@ -4,15 +4,15 @@ 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.audit import record_audit_event
-from hub_platform.integrations.models import Integration
-from hub_platform.integrations.selectors import (
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
+from chatballs.integrations.models import Integration
+from chatballs.integrations.selectors import (
integration_for_context,
integrations_for_context,
)
-from hub_platform.integrations.serializers import integration_payload
-from hub_platform.integrations.services import (
+from chatballs.integrations.serializers import integration_payload
+from chatballs.integrations.services import (
IntegrationInput,
create_integration,
delete_integration,
diff --git a/apps/backend/hub_platform/notifications/__init__.py b/apps/backend/chatballs/notifications/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/__init__.py
rename to apps/backend/chatballs/notifications/__init__.py
diff --git a/apps/backend/chatballs/notifications/admin.py b/apps/backend/chatballs/notifications/admin.py
new file mode 100644
index 0000000..c2428ba
--- /dev/null
+++ b/apps/backend/chatballs/notifications/admin.py
@@ -0,0 +1,15 @@
+from django.contrib import admin
+
+from chatballs.notifications.models import Notification, NotificationRead
+
+
+@admin.register(Notification)
+class NotificationAdmin(admin.ModelAdmin):
+ list_display = ("type", "audience", "level", "title", "organization", "created_at")
+ list_filter = ("type", "audience", "level")
+ search_fields = ("title", "body", "dedup_key")
+
+
+@admin.register(NotificationRead)
+class NotificationReadAdmin(admin.ModelAdmin):
+ list_display = ("notification", "user", "read_at")
diff --git a/apps/backend/chatballs/notifications/apps.py b/apps/backend/chatballs/notifications/apps.py
new file mode 100644
index 0000000..8b1cc0f
--- /dev/null
+++ b/apps/backend/chatballs/notifications/apps.py
@@ -0,0 +1,11 @@
+from django.apps import AppConfig
+
+
+class NotificationsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ label = "notifications"
+ name = "chatballs.notifications"
+ verbose_name = "Notifications"
+
+ def ready(self) -> None:
+ from chatballs.notifications import event_handlers # noqa: F401 (register outbox handlers)
diff --git a/apps/backend/hub_platform/notifications/binding.py b/apps/backend/chatballs/notifications/binding.py
similarity index 94%
rename from apps/backend/hub_platform/notifications/binding.py
rename to apps/backend/chatballs/notifications/binding.py
index d4e0c1a..9849406 100644
--- a/apps/backend/hub_platform/notifications/binding.py
+++ b/apps/backend/chatballs/notifications/binding.py
@@ -14,10 +14,10 @@ from datetime import timedelta
from django.db import transaction
from django.utils import timezone
-from hub_platform.conversations import transports
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
-from hub_platform.notifications.models import MessengerBinding, MessengerBindingCode
+from chatballs.conversations import transports
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
+from chatballs.notifications.models import MessengerBinding, MessengerBindingCode
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/notifications/delivery.py b/apps/backend/chatballs/notifications/delivery.py
similarity index 90%
rename from apps/backend/hub_platform/notifications/delivery.py
rename to apps/backend/chatballs/notifications/delivery.py
index d891023..ce70ccb 100644
--- a/apps/backend/hub_platform/notifications/delivery.py
+++ b/apps/backend/chatballs/notifications/delivery.py
@@ -12,16 +12,16 @@ import logging
from django.conf import settings
-from hub_platform.conversations import transports
-from hub_platform.identity.models import OrganizationMembership
-from hub_platform.notifications.models import (
+from chatballs.conversations import transports
+from chatballs.identity.models import OrganizationMembership
+from chatballs.notifications.models import (
MessengerBinding,
Notification,
NotificationAudience,
NotificationLevel,
)
-from hub_platform.notifications.selectors import visible_for
-from hub_platform.tenancy.context import TenantContext
+from chatballs.notifications.selectors import visible_for
+from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/notifications/event_handlers.py b/apps/backend/chatballs/notifications/event_handlers.py
similarity index 62%
rename from apps/backend/hub_platform/notifications/event_handlers.py
rename to apps/backend/chatballs/notifications/event_handlers.py
index a6b4dcf..330d9cf 100644
--- a/apps/backend/hub_platform/notifications/event_handlers.py
+++ b/apps/backend/chatballs/notifications/event_handlers.py
@@ -1,7 +1,7 @@
-from hub_platform.events.handlers import register
-from hub_platform.notifications.delivery import NOTIFICATION_CREATED, deliver_notification
-from hub_platform.notifications.models import Notification
-from hub_platform.tenancy.context import TenantContext
+from chatballs.events.handlers import register
+from chatballs.notifications.delivery import NOTIFICATION_CREATED, deliver_notification
+from chatballs.notifications.models import Notification
+from chatballs.tenancy.context import TenantContext
@register(NOTIFICATION_CREATED)
diff --git a/apps/backend/hub_platform/notifications/migrations/0001_initial.py b/apps/backend/chatballs/notifications/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0001_initial.py
rename to apps/backend/chatballs/notifications/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0002_alter_notification_type.py b/apps/backend/chatballs/notifications/migrations/0002_alter_notification_type.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0002_alter_notification_type.py
rename to apps/backend/chatballs/notifications/migrations/0002_alter_notification_type.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0003_messengerbindingcode_messengerbinding.py b/apps/backend/chatballs/notifications/migrations/0003_messengerbindingcode_messengerbinding.py
similarity index 95%
rename from apps/backend/hub_platform/notifications/migrations/0003_messengerbindingcode_messengerbinding.py
rename to apps/backend/chatballs/notifications/migrations/0003_messengerbindingcode_messengerbinding.py
index 83b51b3..b736041 100644
--- a/apps/backend/hub_platform/notifications/migrations/0003_messengerbindingcode_messengerbinding.py
+++ b/apps/backend/chatballs/notifications/migrations/0003_messengerbindingcode_messengerbinding.py
@@ -1,7 +1,7 @@
# Generated by Django 5.2.15 on 2026-07-10 21:27
import django.db.models.deletion
-import hub_platform.notifications.models
+import chatballs.notifications.models
from django.conf import settings
from django.db import migrations, models
@@ -31,7 +31,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_chat_id', models.CharField(max_length=128)),
- ('push_types', models.JSONField(blank=True, default=hub_platform.notifications.models.default_push_types)),
+ ('push_types', models.JSONField(blank=True, default=chatballs.notifications.models.default_push_types)),
('created_at', models.DateTimeField(auto_now_add=True)),
('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messenger_bindings', to='integrations.integration')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messenger_bindings', to=settings.AUTH_USER_MODEL)),
diff --git a/apps/backend/hub_platform/notifications/migrations/0004_notification_department_scope.py b/apps/backend/chatballs/notifications/migrations/0004_notification_department_scope.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0004_notification_department_scope.py
rename to apps/backend/chatballs/notifications/migrations/0004_notification_department_scope.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0005_messengerbinding_organization_and_more.py b/apps/backend/chatballs/notifications/migrations/0005_messengerbinding_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0005_messengerbinding_organization_and_more.py
rename to apps/backend/chatballs/notifications/migrations/0005_messengerbinding_organization_and_more.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py b/apps/backend/chatballs/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py
rename to apps/backend/chatballs/notifications/migrations/0006_alter_messengerbinding_organization_and_more.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0007_drop_payment_types.py b/apps/backend/chatballs/notifications/migrations/0007_drop_payment_types.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0007_drop_payment_types.py
rename to apps/backend/chatballs/notifications/migrations/0007_drop_payment_types.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0008_remove_notification_department.py b/apps/backend/chatballs/notifications/migrations/0008_remove_notification_department.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0008_remove_notification_department.py
rename to apps/backend/chatballs/notifications/migrations/0008_remove_notification_department.py
diff --git a/apps/backend/hub_platform/notifications/migrations/0009_alter_notification_type.py b/apps/backend/chatballs/notifications/migrations/0009_alter_notification_type.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/0009_alter_notification_type.py
rename to apps/backend/chatballs/notifications/migrations/0009_alter_notification_type.py
diff --git a/apps/backend/hub_platform/notifications/migrations/__init__.py b/apps/backend/chatballs/notifications/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/notifications/migrations/__init__.py
rename to apps/backend/chatballs/notifications/migrations/__init__.py
diff --git a/apps/backend/hub_platform/notifications/models.py b/apps/backend/chatballs/notifications/models.py
similarity index 98%
rename from apps/backend/hub_platform/notifications/models.py
rename to apps/backend/chatballs/notifications/models.py
index 75e8072..700715c 100644
--- a/apps/backend/hub_platform/notifications/models.py
+++ b/apps/backend/chatballs/notifications/models.py
@@ -1,7 +1,7 @@
from django.conf import settings
from django.db import models
-from hub_platform.tenancy.models import TenantRelationModel
+from chatballs.tenancy.models import TenantRelationModel
# Уведомления: событие создаётся один раз и адресуется аудитории; прочтение —
# персональное (NotificationRead). Фундамент под любые типы событий, не только чат.
diff --git a/apps/backend/hub_platform/notifications/selectors.py b/apps/backend/chatballs/notifications/selectors.py
similarity index 84%
rename from apps/backend/hub_platform/notifications/selectors.py
rename to apps/backend/chatballs/notifications/selectors.py
index 7edec53..8f2f8e9 100644
--- a/apps/backend/hub_platform/notifications/selectors.py
+++ b/apps/backend/chatballs/notifications/selectors.py
@@ -1,8 +1,8 @@
from django.db.models import Q, QuerySet
-from hub_platform.identity.policy import ResourceScope, authorize
-from hub_platform.notifications.models import Notification, NotificationAudience
-from hub_platform.tenancy.context import TenantContext
+from chatballs.identity.policy import ResourceScope, authorize
+from chatballs.notifications.models import Notification, NotificationAudience
+from chatballs.tenancy.context import TenantContext
def visible_for(context: TenantContext) -> QuerySet[Notification]:
diff --git a/apps/backend/chatballs/notifications/serializers.py b/apps/backend/chatballs/notifications/serializers.py
new file mode 100644
index 0000000..07b9b90
--- /dev/null
+++ b/apps/backend/chatballs/notifications/serializers.py
@@ -0,0 +1,15 @@
+from chatballs.notifications.models import Notification
+
+
+def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:
+ return {
+ "id": notification.id,
+ "type": notification.type,
+ "level": notification.level,
+ "title": notification.title,
+ "body": notification.body,
+ "targetRoute": notification.target_route,
+ "targetId": notification.target_id,
+ "createdAt": notification.created_at.isoformat(),
+ "unread": unread,
+ }
diff --git a/apps/backend/hub_platform/notifications/services.py b/apps/backend/chatballs/notifications/services.py
similarity index 91%
rename from apps/backend/hub_platform/notifications/services.py
rename to apps/backend/chatballs/notifications/services.py
index fa34150..1de1bb1 100644
--- a/apps/backend/hub_platform/notifications/services.py
+++ b/apps/backend/chatballs/notifications/services.py
@@ -2,17 +2,17 @@ from datetime import timedelta
from django.utils import timezone
-from hub_platform.events.services import DomainEvent, enqueue_event
-from hub_platform.identity.models import EmployeeRole, OrganizationMembership
-from hub_platform.notifications.delivery import NOTIFICATION_CREATED
-from hub_platform.notifications.models import (
+from chatballs.events.services import DomainEvent, enqueue_event
+from chatballs.identity.models import EmployeeRole, OrganizationMembership
+from chatballs.notifications.delivery import NOTIFICATION_CREATED
+from chatballs.notifications.models import (
Notification,
NotificationAudience,
NotificationLevel,
NotificationRead,
NotificationType,
)
-from hub_platform.notifications.selectors import unread_for
+from chatballs.notifications.selectors import unread_for
# Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события —
# одна запись здесь + вызов notify(...) из доменного сервиса.
diff --git a/apps/backend/hub_platform/notifications/tests.py b/apps/backend/chatballs/notifications/tests.py
similarity index 84%
rename from apps/backend/hub_platform/notifications/tests.py
rename to apps/backend/chatballs/notifications/tests.py
index 6ed21e1..ff1289e 100644
--- a/apps/backend/hub_platform/notifications/tests.py
+++ b/apps/backend/chatballs/notifications/tests.py
@@ -1,19 +1,19 @@
from unittest import mock
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient, tenant_context_for
+from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.events.handlers import dispatch
-from hub_platform.events.models import OutboxEvent
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import HumanUser, Organization
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
-from hub_platform.notifications.binding import deep_link, handle_notifier_inbound, issue_binding_code
-from hub_platform.notifications.delivery import NOTIFICATION_CREATED
-from hub_platform.notifications.models import MessengerBinding, MessengerBindingCode, NotificationAudience, NotificationType
-from hub_platform.notifications.services import notify
-from hub_platform.notifications.selectors import visible_for
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.events.handlers import dispatch
+from chatballs.events.models import OutboxEvent
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import HumanUser, Organization
+from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
+from chatballs.notifications.binding import deep_link, handle_notifier_inbound, issue_binding_code
+from chatballs.notifications.delivery import NOTIFICATION_CREATED
+from chatballs.notifications.models import MessengerBinding, MessengerBindingCode, NotificationAudience, NotificationType
+from chatballs.notifications.services import notify
+from chatballs.notifications.selectors import visible_for
def _notifier(organization, provider=IntegrationProvider.TELEGRAM, username="edevs_notify_bot"):
@@ -46,7 +46,7 @@ class BindingTests(NotifierTestBase):
def test_start_code_creates_binding_and_confirms(self) -> None:
code = issue_binding_code(context=self.context, integration=self.integration)
inbound = InboundMessage(external_id="1", user_id="777", chat_id="777", text=f"/start {code.code}", display_name="Андрей")
- with mock.patch("hub_platform.notifications.binding.transports.send_reply", return_value=True) as send:
+ with mock.patch("chatballs.notifications.binding.transports.send_reply", return_value=True) as send:
handle_notifier_inbound(self.integration, inbound)
binding = MessengerBinding.objects.get(user=self.owner, integration=self.integration)
self.assertEqual(binding.external_chat_id, "777")
@@ -55,7 +55,7 @@ class BindingTests(NotifierTestBase):
def test_unknown_text_gets_hint_without_binding(self) -> None:
inbound = InboundMessage(external_id="2", user_id="777", chat_id="777", text="Привет", display_name="Андрей")
- with mock.patch("hub_platform.notifications.binding.transports.send_reply", return_value=True) as send:
+ with mock.patch("chatballs.notifications.binding.transports.send_reply", return_value=True) as send:
handle_notifier_inbound(self.integration, inbound)
self.assertFalse(MessengerBinding.objects.exists())
self.assertIn("профиль", send.call_args.kwargs["text"])
@@ -66,7 +66,7 @@ class BindingTests(NotifierTestBase):
max_bot = _notifier(self.organization, provider=IntegrationProvider.MAX, username="edevs_max_bot")
code = issue_binding_code(context=self.context, integration=max_bot)
inbound = InboundMessage(external_id="3", user_id="9", chat_id="9", text=f"/start {code.code}", display_name="Андрей")
- with mock.patch("hub_platform.notifications.binding.transports.send_reply", return_value=True):
+ with mock.patch("chatballs.notifications.binding.transports.send_reply", return_value=True):
handle_notifier_inbound(max_bot, inbound)
bindings = list(MessengerBinding.objects.filter(user=self.owner))
self.assertEqual(len(bindings), 1)
@@ -88,7 +88,7 @@ class DeliveryTests(NotifierTestBase):
dispatch(event)
def test_notify_enqueues_and_delivers_to_binding(self) -> None:
- with mock.patch("hub_platform.notifications.delivery.transports.send_reply", return_value=True) as send:
+ with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send:
notify(
context=self.context,
type=NotificationType.DIALOG_WAITING,
@@ -103,7 +103,7 @@ class DeliveryTests(NotifierTestBase):
def test_type_not_in_push_types_is_skipped(self) -> None:
# INTEGRATION_ERROR не входит в дефолтные push_types привязки.
- with mock.patch("hub_platform.notifications.delivery.transports.send_reply", return_value=True) as send:
+ with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send:
notify(
context=self.context,
type=NotificationType.INTEGRATION_ERROR,
@@ -115,7 +115,7 @@ class DeliveryTests(NotifierTestBase):
def test_user_audience_only_reaches_recipient(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
- with mock.patch("hub_platform.notifications.delivery.transports.send_reply", return_value=True) as send:
+ with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send:
notify(
context=self.context,
type=NotificationType.DIALOG_NEW_MESSAGE,
@@ -153,23 +153,23 @@ class PollerSelectionTests(NotifierTestBase):
без ключа purpose — клиентские TG/MAX боты переставали поллиться."""
def test_client_bot_still_polled_notifier_excluded(self) -> None:
- from hub_platform.channels.models import Channel
- from hub_platform.conversations import poller
+ from chatballs.channels.models import Channel
+ from chatballs.conversations import poller
channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи")
client_bot = Integration.objects.create(
organization=self.organization, kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM, name="client-bot", secret="token", channel=channel,
)
- with mock.patch("hub_platform.conversations.poller.transports.poll", return_value=([], "")) as poll:
+ with mock.patch("chatballs.conversations.poller.transports.poll", return_value=([], "")) as poll:
poller.poll_all_messengers(self.context)
polled_ids = [call.args[0].id for call in poll.call_args_list]
self.assertIn(client_bot.id, polled_ids)
self.assertNotIn(self.integration.id, polled_ids)
def test_disabled_connection_and_inactive_channel_are_not_polled(self) -> None:
- from hub_platform.channels.models import Channel
- from hub_platform.conversations import poller
+ from chatballs.channels.models import Channel
+ from chatballs.conversations import poller
active_channel = Channel.objects.create(
organization=self.organization,
@@ -201,7 +201,7 @@ class PollerSelectionTests(NotifierTestBase):
)
with mock.patch(
- "hub_platform.conversations.poller.transports.poll",
+ "chatballs.conversations.poller.transports.poll",
return_value=([], ""),
) as poll:
poller.poll_all_messengers(self.context)
diff --git a/apps/backend/hub_platform/notifications/urls.py b/apps/backend/chatballs/notifications/urls.py
similarity index 62%
rename from apps/backend/hub_platform/notifications/urls.py
rename to apps/backend/chatballs/notifications/urls.py
index 0a2015f..2110bb7 100644
--- a/apps/backend/hub_platform/notifications/urls.py
+++ b/apps/backend/chatballs/notifications/urls.py
@@ -1,10 +1,10 @@
-from django.urls import path
-
-from hub_platform.notifications import views
-
-urlpatterns = [
- path("", views.NotificationListView.as_view(), name="notification-list"),
- path("read/", views.NotificationReadView.as_view(), name="notification-read"),
- path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"),
- path("messenger-bindings//", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"),
-]
+from django.urls import path
+
+from chatballs.notifications import views
+
+urlpatterns = [
+ path("", views.NotificationListView.as_view(), name="notification-list"),
+ path("read/", views.NotificationReadView.as_view(), name="notification-read"),
+ path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"),
+ path("messenger-bindings//", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"),
+]
diff --git a/apps/backend/hub_platform/notifications/views.py b/apps/backend/chatballs/notifications/views.py
similarity index 92%
rename from apps/backend/hub_platform/notifications/views.py
rename to apps/backend/chatballs/notifications/views.py
index 9059763..cc1576c 100644
--- a/apps/backend/hub_platform/notifications/views.py
+++ b/apps/backend/chatballs/notifications/views.py
@@ -3,11 +3,11 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.notifications.binding import deep_link, issue_binding_code, notifier_integrations
-from hub_platform.notifications.models import MessengerBinding, NotificationRead, NotificationType
-from hub_platform.notifications.selectors import unread_for, visible_for
-from hub_platform.notifications.serializers import notification_payload
-from hub_platform.notifications.services import TYPE_META, mark_read
+from chatballs.notifications.binding import deep_link, issue_binding_code, notifier_integrations
+from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType
+from chatballs.notifications.selectors import unread_for, visible_for
+from chatballs.notifications.serializers import notification_payload
+from chatballs.notifications.services import TYPE_META, mark_read
_LIST_LIMIT = 50
diff --git a/apps/backend/hub_platform/platform/__init__.py b/apps/backend/chatballs/platform/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/platform/__init__.py
rename to apps/backend/chatballs/platform/__init__.py
diff --git a/apps/backend/hub_platform/platform/apps.py b/apps/backend/chatballs/platform/apps.py
similarity index 82%
rename from apps/backend/hub_platform/platform/apps.py
rename to apps/backend/chatballs/platform/apps.py
index e0a5eca..b3fc403 100644
--- a/apps/backend/hub_platform/platform/apps.py
+++ b/apps/backend/chatballs/platform/apps.py
@@ -3,5 +3,5 @@ from django.apps import AppConfig
class PlatformConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.platform"
+ name = "chatballs.platform"
verbose_name = "Platform control plane"
diff --git a/apps/backend/hub_platform/platform/authentication.py b/apps/backend/chatballs/platform/authentication.py
similarity index 91%
rename from apps/backend/hub_platform/platform/authentication.py
rename to apps/backend/chatballs/platform/authentication.py
index 77d0ea4..fe99a8f 100644
--- a/apps/backend/hub_platform/platform/authentication.py
+++ b/apps/backend/chatballs/platform/authentication.py
@@ -2,8 +2,8 @@ from __future__ import annotations
from rest_framework.authentication import BaseAuthentication
-from hub_platform.platform.models import PlatformOperator, PlatformToken
-from hub_platform.platform.tokens import authenticate_token
+from chatballs.platform.models import PlatformOperator, PlatformToken
+from chatballs.platform.tokens import authenticate_token
class PlatformTokenAuthentication(BaseAuthentication):
diff --git a/apps/backend/hub_platform/platform/capabilities.py b/apps/backend/chatballs/platform/capabilities.py
similarity index 100%
rename from apps/backend/hub_platform/platform/capabilities.py
rename to apps/backend/chatballs/platform/capabilities.py
diff --git a/apps/backend/hub_platform/platform/errors.py b/apps/backend/chatballs/platform/errors.py
similarity index 100%
rename from apps/backend/hub_platform/platform/errors.py
rename to apps/backend/chatballs/platform/errors.py
diff --git a/apps/backend/hub_platform/platform/management/__init__.py b/apps/backend/chatballs/platform/management/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/platform/management/__init__.py
rename to apps/backend/chatballs/platform/management/__init__.py
diff --git a/apps/backend/hub_platform/platform/management/commands/__init__.py b/apps/backend/chatballs/platform/management/commands/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/platform/management/commands/__init__.py
rename to apps/backend/chatballs/platform/management/commands/__init__.py
diff --git a/apps/backend/hub_platform/platform/management/commands/create_platform_token.py b/apps/backend/chatballs/platform/management/commands/create_platform_token.py
similarity index 91%
rename from apps/backend/hub_platform/platform/management/commands/create_platform_token.py
rename to apps/backend/chatballs/platform/management/commands/create_platform_token.py
index bd467fb..79bfeb6 100644
--- a/apps/backend/hub_platform/platform/management/commands/create_platform_token.py
+++ b/apps/backend/chatballs/platform/management/commands/create_platform_token.py
@@ -2,9 +2,9 @@ from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
-from hub_platform.platform.capabilities import PLATFORM_CAPABILITIES
-from hub_platform.platform.models import PlatformOperator
-from hub_platform.platform.tokens import issue_platform_token
+from chatballs.platform.capabilities import PLATFORM_CAPABILITIES
+from chatballs.platform.models import PlatformOperator
+from chatballs.platform.tokens import issue_platform_token
class Command(BaseCommand):
diff --git a/apps/backend/hub_platform/platform/migrations/0001_initial.py b/apps/backend/chatballs/platform/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/platform/migrations/0001_initial.py
rename to apps/backend/chatballs/platform/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/platform/migrations/__init__.py b/apps/backend/chatballs/platform/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/platform/migrations/__init__.py
rename to apps/backend/chatballs/platform/migrations/__init__.py
diff --git a/apps/backend/hub_platform/platform/models.py b/apps/backend/chatballs/platform/models.py
similarity index 66%
rename from apps/backend/hub_platform/platform/models.py
rename to apps/backend/chatballs/platform/models.py
index 96e0408..938efc4 100644
--- a/apps/backend/hub_platform/platform/models.py
+++ b/apps/backend/chatballs/platform/models.py
@@ -1,10 +1,10 @@
# Django imports only models.py by convention. Re-export platform models so they
# are registered without growing this file beyond an aggregator.
-from hub_platform.platform.operator_models import ( # noqa: F401
+from chatballs.platform.operator_models import ( # noqa: F401
PlatformOperator,
PlatformToken,
)
-from hub_platform.platform.provisioning_models import ( # noqa: F401
+from chatballs.platform.provisioning_models import ( # noqa: F401
OrganizationProvisioning,
ProvisioningSource,
ProvisioningStatus,
diff --git a/apps/backend/hub_platform/platform/operator_models.py b/apps/backend/chatballs/platform/operator_models.py
similarity index 100%
rename from apps/backend/hub_platform/platform/operator_models.py
rename to apps/backend/chatballs/platform/operator_models.py
diff --git a/apps/backend/hub_platform/platform/payloads.py b/apps/backend/chatballs/platform/payloads.py
similarity index 86%
rename from apps/backend/hub_platform/platform/payloads.py
rename to apps/backend/chatballs/platform/payloads.py
index f34c124..85d766b 100644
--- a/apps/backend/hub_platform/platform/payloads.py
+++ b/apps/backend/chatballs/platform/payloads.py
@@ -2,9 +2,9 @@ from __future__ import annotations
from typing import Any
-from hub_platform.identity.models import Organization, OrganizationMembership
-from hub_platform.platform.models import OrganizationProvisioning
-from hub_platform.platform.provisioning_models import ProvisioningStatus
+from chatballs.identity.models import Organization, OrganizationMembership
+from chatballs.platform.models import OrganizationProvisioning
+from chatballs.platform.provisioning_models import ProvisioningStatus
_PROVISIONING_SOURCE_DEFAULT = "PLATFORM_OPERATOR"
diff --git a/apps/backend/hub_platform/platform/permissions.py b/apps/backend/chatballs/platform/permissions.py
similarity index 93%
rename from apps/backend/hub_platform/platform/permissions.py
rename to apps/backend/chatballs/platform/permissions.py
index 788feab..3c5d20f 100644
--- a/apps/backend/hub_platform/platform/permissions.py
+++ b/apps/backend/chatballs/platform/permissions.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from rest_framework.permissions import BasePermission
-from hub_platform.platform.models import PlatformOperator
+from chatballs.platform.models import PlatformOperator
class HasPlatformCapability(BasePermission):
diff --git a/apps/backend/hub_platform/platform/provisioning_command.py b/apps/backend/chatballs/platform/provisioning_command.py
similarity index 95%
rename from apps/backend/hub_platform/platform/provisioning_command.py
rename to apps/backend/chatballs/platform/provisioning_command.py
index 1eca0e4..4a49a3b 100644
--- a/apps/backend/hub_platform/platform/provisioning_command.py
+++ b/apps/backend/chatballs/platform/provisioning_command.py
@@ -5,8 +5,8 @@ import json
from dataclasses import asdict, dataclass
from typing import Any
-from hub_platform.identity.models import Organization
-from hub_platform.platform.provisioning_models import (
+from chatballs.identity.models import Organization
+from chatballs.platform.provisioning_models import (
OrganizationProvisioning,
ProvisioningSource,
ProvisioningStatus,
diff --git a/apps/backend/hub_platform/platform/provisioning_models.py b/apps/backend/chatballs/platform/provisioning_models.py
similarity index 97%
rename from apps/backend/hub_platform/platform/provisioning_models.py
rename to apps/backend/chatballs/platform/provisioning_models.py
index 6707a3c..13437eb 100644
--- a/apps/backend/hub_platform/platform/provisioning_models.py
+++ b/apps/backend/chatballs/platform/provisioning_models.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from django.db import models
-from hub_platform.platform.operator_models import PlatformOperator
+from chatballs.platform.operator_models import PlatformOperator
class ProvisioningSource(models.TextChoices):
diff --git a/apps/backend/hub_platform/platform/provisioning_service.py b/apps/backend/chatballs/platform/provisioning_service.py
similarity index 93%
rename from apps/backend/hub_platform/platform/provisioning_service.py
rename to apps/backend/chatballs/platform/provisioning_service.py
index 13ca5e6..af41f50 100644
--- a/apps/backend/hub_platform/platform/provisioning_service.py
+++ b/apps/backend/chatballs/platform/provisioning_service.py
@@ -6,35 +6,35 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.events.services import DomainEvent, enqueue_event
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.invitation_service import issue_invitation
-from hub_platform.identity.models import (
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.events.services import DomainEvent, enqueue_event
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.invitation_service import issue_invitation
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
-from hub_platform.platform.errors import (
+from chatballs.platform.errors import (
ProvisioningConflict,
ProvisioningOwnerUnavailable,
ProvisioningValidation,
)
-from hub_platform.platform.models import (
+from chatballs.platform.models import (
OrganizationProvisioning,
PlatformOperator,
ProvisioningStatus,
)
-from hub_platform.platform.provisioning_command import (
+from chatballs.platform.provisioning_command import (
ProvisioningCommand,
ProvisioningResult,
is_replay,
is_terminal,
)
-from hub_platform.tenancy.context import TenantActorKind, TenantContext
-from hub_platform.tenancy.database import tenant_atomic
+from chatballs.tenancy.context import TenantActorKind, TenantContext
+from chatballs.tenancy.database import tenant_atomic
OWNER_INVITATION_TTL = timedelta(days=7)
diff --git a/apps/backend/hub_platform/platform/testing.py b/apps/backend/chatballs/platform/testing.py
similarity index 75%
rename from apps/backend/hub_platform/platform/testing.py
rename to apps/backend/chatballs/platform/testing.py
index 863af6f..d7e5891 100644
--- a/apps/backend/hub_platform/platform/testing.py
+++ b/apps/backend/chatballs/platform/testing.py
@@ -1,8 +1,8 @@
from __future__ import annotations
-from hub_platform.platform.capabilities import is_valid_platform_capability
-from hub_platform.platform.models import PlatformOperator
-from hub_platform.platform.tokens import issue_platform_token
+from chatballs.platform.capabilities import is_valid_platform_capability
+from chatballs.platform.models import PlatformOperator
+from chatballs.platform.tokens import issue_platform_token
def create_platform_operator(
diff --git a/apps/backend/hub_platform/platform/tests/__init__.py b/apps/backend/chatballs/platform/tests/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/platform/tests/__init__.py
rename to apps/backend/chatballs/platform/tests/__init__.py
diff --git a/apps/backend/hub_platform/platform/tests/test_create_token_command.py b/apps/backend/chatballs/platform/tests/test_create_token_command.py
similarity index 92%
rename from apps/backend/hub_platform/platform/tests/test_create_token_command.py
rename to apps/backend/chatballs/platform/tests/test_create_token_command.py
index 08bc62f..afc0878 100644
--- a/apps/backend/hub_platform/platform/tests/test_create_token_command.py
+++ b/apps/backend/chatballs/platform/tests/test_create_token_command.py
@@ -6,8 +6,8 @@ from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
-from hub_platform.platform.models import PlatformOperator, PlatformToken
-from hub_platform.platform.tokens import authenticate_token
+from chatballs.platform.models import PlatformOperator, PlatformToken
+from chatballs.platform.tokens import authenticate_token
class CreatePlatformTokenCommandTests(TestCase):
diff --git a/apps/backend/hub_platform/platform/tests/test_platform_auth.py b/apps/backend/chatballs/platform/tests/test_platform_auth.py
similarity index 91%
rename from apps/backend/hub_platform/platform/tests/test_platform_auth.py
rename to apps/backend/chatballs/platform/tests/test_platform_auth.py
index b3dbe00..4b40bb3 100644
--- a/apps/backend/hub_platform/platform/tests/test_platform_auth.py
+++ b/apps/backend/chatballs/platform/tests/test_platform_auth.py
@@ -3,8 +3,8 @@ from __future__ import annotations
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
-from hub_platform.platform.models import PlatformToken
-from hub_platform.platform.testing import create_platform_operator
+from chatballs.platform.models import PlatformToken
+from chatballs.platform.testing import create_platform_operator
def _client(token: str | None) -> APIClient:
@@ -14,7 +14,7 @@ def _client(token: str | None) -> APIClient:
return client
-@override_settings(ROOT_URLCONF="hub_backend.urls_platform")
+@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
class PlatformAuthTests(TestCase):
def setUp(self) -> None:
self.operator, self.token = create_platform_operator()
@@ -58,7 +58,7 @@ class PlatformAuthTests(TestCase):
self.assertEqual(response.status_code, 401)
-@override_settings(ROOT_URLCONF="hub_backend.urls_platform")
+@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
class PlatformCapabilityGateTests(TestCase):
def test_token_without_capability_is_forbidden(self) -> None:
operator, _token = create_platform_operator(capabilities=[])
diff --git a/apps/backend/hub_platform/platform/tests/test_provisioning.py b/apps/backend/chatballs/platform/tests/test_provisioning.py
similarity index 91%
rename from apps/backend/hub_platform/platform/tests/test_provisioning.py
rename to apps/backend/chatballs/platform/tests/test_provisioning.py
index ddca12d..60728f2 100644
--- a/apps/backend/hub_platform/platform/tests/test_provisioning.py
+++ b/apps/backend/chatballs/platform/tests/test_provisioning.py
@@ -2,22 +2,22 @@ from __future__ import annotations
from django.test import TestCase
-from hub_platform.ai.models import AIAgent
-from hub_platform.identity.models import (
+from chatballs.ai.models import AIAgent
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
-from hub_platform.platform.errors import ProvisioningConflict
-from hub_platform.platform.models import (
+from chatballs.platform.errors import ProvisioningConflict
+from chatballs.platform.models import (
OrganizationProvisioning,
ProvisioningStatus,
)
-from hub_platform.platform.provisioning_command import ProvisioningCommand
-from hub_platform.platform.provisioning_service import provision_organization
-from hub_platform.platform.testing import create_platform_operator
+from chatballs.platform.provisioning_command import ProvisioningCommand
+from chatballs.platform.provisioning_service import provision_organization
+from chatballs.platform.testing import create_platform_operator
def _command(
diff --git a/apps/backend/hub_platform/platform/tokens.py b/apps/backend/chatballs/platform/tokens.py
similarity index 87%
rename from apps/backend/hub_platform/platform/tokens.py
rename to apps/backend/chatballs/platform/tokens.py
index b162d68..15f95cd 100644
--- a/apps/backend/hub_platform/platform/tokens.py
+++ b/apps/backend/chatballs/platform/tokens.py
@@ -6,10 +6,10 @@ import secrets
from django.db import transaction
from django.utils import timezone
-from hub_platform.platform.capabilities import is_valid_platform_capability
-from hub_platform.platform.models import PlatformOperator, PlatformToken
+from chatballs.platform.capabilities import is_valid_platform_capability
+from chatballs.platform.models import PlatformOperator, PlatformToken
-_TOKEN_SCHEME = "ctp" # custocrm-platform; makes platform tokens visually distinct
+_TOKEN_SCHEME = "ctp" # chatballs-platform; makes platform tokens visually distinct
def hash_token(token: str) -> str:
diff --git a/apps/backend/hub_platform/platform/urls.py b/apps/backend/chatballs/platform/urls.py
similarity index 82%
rename from apps/backend/hub_platform/platform/urls.py
rename to apps/backend/chatballs/platform/urls.py
index 2ee8627..47762e7 100644
--- a/apps/backend/hub_platform/platform/urls.py
+++ b/apps/backend/chatballs/platform/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.platform import views
+from chatballs.platform import views
urlpatterns = [
path(
diff --git a/apps/backend/hub_platform/platform/validation.py b/apps/backend/chatballs/platform/validation.py
similarity index 92%
rename from apps/backend/hub_platform/platform/validation.py
rename to apps/backend/chatballs/platform/validation.py
index 427d09e..edf5be7 100644
--- a/apps/backend/hub_platform/platform/validation.py
+++ b/apps/backend/chatballs/platform/validation.py
@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import Any
-from hub_platform.platform.provisioning_command import ProvisioningCommand
-from hub_platform.platform.provisioning_models import ProvisioningSource
+from chatballs.platform.provisioning_command import ProvisioningCommand
+from chatballs.platform.provisioning_models import ProvisioningSource
_REQUIRED_FIELDS = (
"name",
diff --git a/apps/backend/hub_platform/platform/views.py b/apps/backend/chatballs/platform/views.py
similarity index 77%
rename from apps/backend/hub_platform/platform/views.py
rename to apps/backend/chatballs/platform/views.py
index 6f6edd8..4f656e5 100644
--- a/apps/backend/hub_platform/platform/views.py
+++ b/apps/backend/chatballs/platform/views.py
@@ -4,14 +4,14 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.identity.models import EmployeeRole, OrganizationMembership
-from hub_platform.platform.authentication import PlatformTokenAuthentication
-from hub_platform.platform.errors import ProvisioningError
-from hub_platform.platform.models import ProvisioningSource
-from hub_platform.platform.payloads import owner_state_for, provisioning_result_payload
-from hub_platform.platform.permissions import HasPlatformCapability
-from hub_platform.platform.provisioning_service import provision_organization
-from hub_platform.platform.validation import parse_provisioning_body
+from chatballs.identity.models import EmployeeRole, OrganizationMembership
+from chatballs.platform.authentication import PlatformTokenAuthentication
+from chatballs.platform.errors import ProvisioningError
+from chatballs.platform.models import ProvisioningSource
+from chatballs.platform.payloads import owner_state_for, provisioning_result_payload
+from chatballs.platform.permissions import HasPlatformCapability
+from chatballs.platform.provisioning_service import provision_organization
+from chatballs.platform.validation import parse_provisioning_body
_IDEMPOTENCY_HEADER = "Idempotency-Key"
diff --git a/apps/backend/hub_platform/identity/migrations/__init__.py b/apps/backend/chatballs/products/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/identity/migrations/__init__.py
rename to apps/backend/chatballs/products/__init__.py
diff --git a/apps/backend/hub_platform/products/admin.py b/apps/backend/chatballs/products/admin.py
similarity index 83%
rename from apps/backend/hub_platform/products/admin.py
rename to apps/backend/chatballs/products/admin.py
index 34593a2..8a8e63e 100644
--- a/apps/backend/hub_platform/products/admin.py
+++ b/apps/backend/chatballs/products/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from hub_platform.products.models import Product
+from chatballs.products.models import Product
@admin.register(Product)
diff --git a/apps/backend/hub_platform/products/apps.py b/apps/backend/chatballs/products/apps.py
similarity index 78%
rename from apps/backend/hub_platform/products/apps.py
rename to apps/backend/chatballs/products/apps.py
index 6481a94..f926856 100644
--- a/apps/backend/hub_platform/products/apps.py
+++ b/apps/backend/chatballs/products/apps.py
@@ -3,5 +3,5 @@ from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.products"
+ name = "chatballs.products"
diff --git a/apps/backend/hub_platform/products/migrations/0001_move_product_state.py b/apps/backend/chatballs/products/migrations/0001_move_product_state.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0001_move_product_state.py
rename to apps/backend/chatballs/products/migrations/0001_move_product_state.py
diff --git a/apps/backend/hub_platform/products/migrations/0002_product_sales_description_product_summary_and_more.py b/apps/backend/chatballs/products/migrations/0002_product_sales_description_product_summary_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0002_product_sales_description_product_summary_and_more.py
rename to apps/backend/chatballs/products/migrations/0002_product_sales_description_product_summary_and_more.py
diff --git a/apps/backend/hub_platform/products/migrations/0003_assign_existing_products_to_sales.py b/apps/backend/chatballs/products/migrations/0003_assign_existing_products_to_sales.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0003_assign_existing_products_to_sales.py
rename to apps/backend/chatballs/products/migrations/0003_assign_existing_products_to_sales.py
diff --git a/apps/backend/hub_platform/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py b/apps/backend/chatballs/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py
rename to apps/backend/chatballs/products/migrations/0004_remove_price_uniq_price_offer_version_and_more.py
diff --git a/apps/backend/hub_platform/products/migrations/0005_remove_price_price_amount_positive_and_more.py b/apps/backend/chatballs/products/migrations/0005_remove_price_price_amount_positive_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0005_remove_price_price_amount_positive_and_more.py
rename to apps/backend/chatballs/products/migrations/0005_remove_price_price_amount_positive_and_more.py
diff --git a/apps/backend/hub_platform/products/migrations/0006_product_ingest_token_hash.py b/apps/backend/chatballs/products/migrations/0006_product_ingest_token_hash.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0006_product_ingest_token_hash.py
rename to apps/backend/chatballs/products/migrations/0006_product_ingest_token_hash.py
diff --git a/apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py b/apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py
new file mode 100644
index 0000000..161ac54
--- /dev/null
+++ b/apps/backend/chatballs/products/migrations/0007_product_support_token_secret.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.15 on 2026-07-08 21:29
+
+import chatballs.identity.crypto
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0006_product_ingest_token_hash'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='product',
+ name='support_token_secret',
+ field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=512),
+ ),
+ ]
diff --git a/apps/backend/hub_platform/products/migrations/0008_remove_product_knowledge_fields.py b/apps/backend/chatballs/products/migrations/0008_remove_product_knowledge_fields.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0008_remove_product_knowledge_fields.py
rename to apps/backend/chatballs/products/migrations/0008_remove_product_knowledge_fields.py
diff --git a/apps/backend/hub_platform/products/migrations/0009_marketplacepublication_organization_and_more.py b/apps/backend/chatballs/products/migrations/0009_marketplacepublication_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0009_marketplacepublication_organization_and_more.py
rename to apps/backend/chatballs/products/migrations/0009_marketplacepublication_organization_and_more.py
diff --git a/apps/backend/hub_platform/products/migrations/0010_alter_marketplacepublication_organization_and_more.py b/apps/backend/chatballs/products/migrations/0010_alter_marketplacepublication_organization_and_more.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0010_alter_marketplacepublication_organization_and_more.py
rename to apps/backend/chatballs/products/migrations/0010_alter_marketplacepublication_organization_and_more.py
diff --git a/apps/backend/hub_platform/products/migrations/0011_remove_commerce.py b/apps/backend/chatballs/products/migrations/0011_remove_commerce.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0011_remove_commerce.py
rename to apps/backend/chatballs/products/migrations/0011_remove_commerce.py
diff --git a/apps/backend/hub_platform/products/migrations/0012_delete_productdepartment.py b/apps/backend/chatballs/products/migrations/0012_delete_productdepartment.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/0012_delete_productdepartment.py
rename to apps/backend/chatballs/products/migrations/0012_delete_productdepartment.py
diff --git a/apps/backend/hub_platform/products/__init__.py b/apps/backend/chatballs/products/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/products/__init__.py
rename to apps/backend/chatballs/products/migrations/__init__.py
diff --git a/apps/backend/hub_platform/products/models.py b/apps/backend/chatballs/products/models.py
similarity index 96%
rename from apps/backend/hub_platform/products/models.py
rename to apps/backend/chatballs/products/models.py
index 5ae94e9..c72bd72 100644
--- a/apps/backend/hub_platform/products/models.py
+++ b/apps/backend/chatballs/products/models.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import models
-from hub_platform.identity.crypto import EncryptedCharField
+from chatballs.identity.crypto import EncryptedCharField
class ProductStatus(models.TextChoices):
diff --git a/apps/backend/hub_platform/products/selectors.py b/apps/backend/chatballs/products/selectors.py
similarity index 82%
rename from apps/backend/hub_platform/products/selectors.py
rename to apps/backend/chatballs/products/selectors.py
index 6b0d194..249dd2a 100644
--- a/apps/backend/hub_platform/products/selectors.py
+++ b/apps/backend/chatballs/products/selectors.py
@@ -1,7 +1,7 @@
from django.db.models import QuerySet
-from hub_platform.products.models import Product
-from hub_platform.tenancy.context import TenantContext
+from chatballs.products.models import Product
+from chatballs.tenancy.context import TenantContext
def products_for_context(context: TenantContext) -> QuerySet[Product]:
diff --git a/apps/backend/hub_platform/products/serializers.py b/apps/backend/chatballs/products/serializers.py
similarity index 96%
rename from apps/backend/hub_platform/products/serializers.py
rename to apps/backend/chatballs/products/serializers.py
index 8cd0293..c7b1d43 100644
--- a/apps/backend/hub_platform/products/serializers.py
+++ b/apps/backend/chatballs/products/serializers.py
@@ -1,4 +1,4 @@
-from hub_platform.products.models import Product
+from chatballs.products.models import Product
def _channel_payload(channel: object) -> dict[str, object]:
diff --git a/apps/backend/hub_platform/products/services.py b/apps/backend/chatballs/products/services.py
similarity index 92%
rename from apps/backend/hub_platform/products/services.py
rename to apps/backend/chatballs/products/services.py
index 8aeab5b..a38d3a0 100644
--- a/apps/backend/hub_platform/products/services.py
+++ b/apps/backend/chatballs/products/services.py
@@ -3,8 +3,8 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.products.models import Product, ProductStatus
-from hub_platform.tenancy.context import TenantContext
+from chatballs.products.models import Product, ProductStatus
+from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True)
diff --git a/apps/backend/hub_platform/products/tests.py b/apps/backend/chatballs/products/tests.py
similarity index 90%
rename from apps/backend/hub_platform/products/tests.py
rename to apps/backend/chatballs/products/tests.py
index da64db2..7c01d72 100644
--- a/apps/backend/hub_platform/products/tests.py
+++ b/apps/backend/chatballs/products/tests.py
@@ -1,11 +1,11 @@
import json
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.products.models import Product, ProductStatus
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.products.models import Product, ProductStatus
class ProductApiTests(TestCase):
@@ -59,7 +59,7 @@ class ProductApiTests(TestCase):
self.assertEqual(response.status_code, 404)
def test_product_payload_exposes_assigned_channels(self) -> None:
- from hub_platform.channels.models import Channel
+ from chatballs.channels.models import Channel
product = Product.objects.get(code="firepage")
Channel.objects.create(organization=self.organization, code="site", name="Сайт", product=product)
diff --git a/apps/backend/hub_platform/products/urls.py b/apps/backend/chatballs/products/urls.py
similarity index 94%
rename from apps/backend/hub_platform/products/urls.py
rename to apps/backend/chatballs/products/urls.py
index b68cf66..a5c88e0 100644
--- a/apps/backend/hub_platform/products/urls.py
+++ b/apps/backend/chatballs/products/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.products import views
+from chatballs.products import views
urlpatterns = [
path("products/", views.ProductListView.as_view(), name="product-list"),
diff --git a/apps/backend/hub_platform/products/views.py b/apps/backend/chatballs/products/views.py
similarity index 92%
rename from apps/backend/hub_platform/products/views.py
rename to apps/backend/chatballs/products/views.py
index d6a4d58..9e21a74 100644
--- a/apps/backend/hub_platform/products/views.py
+++ b/apps/backend/chatballs/products/views.py
@@ -4,12 +4,12 @@ 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.audit import record_audit_event
-from hub_platform.products.models import Product, ProductStatus
-from hub_platform.products.selectors import product_for_context, products_for_context
-from hub_platform.products.serializers import product_payload
-from hub_platform.products.services import ProductInput, create_product, set_product_status, update_product
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
+from chatballs.products.models import Product, ProductStatus
+from chatballs.products.selectors import product_for_context, products_for_context
+from chatballs.products.serializers import product_payload
+from chatballs.products.services import ProductInput, create_product, set_product_status, update_product
def _input(body: dict[str, object], *, current: Product | None = None) -> ProductInput:
return ProductInput(
code=str(body.get("code", current.code if current else "")),
diff --git a/apps/backend/hub_platform/support/__init__.py b/apps/backend/chatballs/support/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/support/__init__.py
rename to apps/backend/chatballs/support/__init__.py
diff --git a/apps/backend/hub_platform/support/admin.py b/apps/backend/chatballs/support/admin.py
similarity index 89%
rename from apps/backend/hub_platform/support/admin.py
rename to apps/backend/chatballs/support/admin.py
index 34871c3..6b6c1c5 100644
--- a/apps/backend/hub_platform/support/admin.py
+++ b/apps/backend/chatballs/support/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from hub_platform.support.models import ProductSupportContract, SupportIdentitySnapshot
+from chatballs.support.models import ProductSupportContract, SupportIdentitySnapshot
@admin.register(ProductSupportContract)
diff --git a/apps/backend/hub_platform/support/apps.py b/apps/backend/chatballs/support/apps.py
similarity index 86%
rename from apps/backend/hub_platform/support/apps.py
rename to apps/backend/chatballs/support/apps.py
index 14dd032..7d5498e 100644
--- a/apps/backend/hub_platform/support/apps.py
+++ b/apps/backend/chatballs/support/apps.py
@@ -4,5 +4,5 @@ from django.apps import AppConfig
class SupportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "support"
- name = "hub_platform.support"
+ name = "chatballs.support"
verbose_name = "Support department and product identity contracts"
diff --git a/apps/backend/hub_platform/support/errors.py b/apps/backend/chatballs/support/errors.py
similarity index 100%
rename from apps/backend/hub_platform/support/errors.py
rename to apps/backend/chatballs/support/errors.py
diff --git a/apps/backend/hub_platform/support/extract.py b/apps/backend/chatballs/support/extract.py
similarity index 97%
rename from apps/backend/hub_platform/support/extract.py
rename to apps/backend/chatballs/support/extract.py
index d19801d..7913b36 100644
--- a/apps/backend/hub_platform/support/extract.py
+++ b/apps/backend/chatballs/support/extract.py
@@ -9,8 +9,8 @@ from __future__ import annotations
from typing import Any
-from hub_platform.support import errors, jsonpath
-from hub_platform.support.models import ProductSupportContract
+from chatballs.support import errors, jsonpath
+from chatballs.support.models import ProductSupportContract
def validate_schema(data: dict[str, Any], schema: dict[str, Any]) -> None:
diff --git a/apps/backend/hub_platform/support/jsonpath.py b/apps/backend/chatballs/support/jsonpath.py
similarity index 100%
rename from apps/backend/hub_platform/support/jsonpath.py
rename to apps/backend/chatballs/support/jsonpath.py
diff --git a/apps/backend/hub_platform/support/messages.py b/apps/backend/chatballs/support/messages.py
similarity index 93%
rename from apps/backend/hub_platform/support/messages.py
rename to apps/backend/chatballs/support/messages.py
index 6c95781..7bdaa43 100644
--- a/apps/backend/hub_platform/support/messages.py
+++ b/apps/backend/chatballs/support/messages.py
@@ -12,20 +12,20 @@ import logging
from django.db import transaction
from django.utils import timezone
-from hub_platform.ai.limits import LimitExceeded
-from hub_platform.ai.provider.base import ProviderError
-from hub_platform.ai.runtime import HANDOFF_TOKEN
-from hub_platform.channels.runtime import run_channel_turn
-from hub_platform.conversations.models import (
+from chatballs.ai.limits import LimitExceeded
+from chatballs.ai.provider.base import ProviderError
+from chatballs.ai.runtime import HANDOFF_TOKEN
+from chatballs.channels.runtime import run_channel_turn
+from chatballs.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
Message,
MessageAuthor,
)
-from hub_platform.notifications.models import NotificationAudience, NotificationType
-from hub_platform.notifications.services import notify, notify_management
-from hub_platform.tenancy.context import TenantContext
+from chatballs.notifications.models import NotificationAudience, NotificationType
+from chatballs.notifications.services import notify, notify_management
+from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
diff --git a/apps/backend/hub_platform/support/migrations/0001_initial.py b/apps/backend/chatballs/support/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/support/migrations/0001_initial.py
rename to apps/backend/chatballs/support/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/support/migrations/0002_alter_productsupportcontract_code.py b/apps/backend/chatballs/support/migrations/0002_alter_productsupportcontract_code.py
similarity index 100%
rename from apps/backend/hub_platform/support/migrations/0002_alter_productsupportcontract_code.py
rename to apps/backend/chatballs/support/migrations/0002_alter_productsupportcontract_code.py
diff --git a/apps/backend/hub_platform/support/migrations/__init__.py b/apps/backend/chatballs/support/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/support/migrations/__init__.py
rename to apps/backend/chatballs/support/migrations/__init__.py
diff --git a/apps/backend/hub_platform/support/models.py b/apps/backend/chatballs/support/models.py
similarity index 100%
rename from apps/backend/hub_platform/support/models.py
rename to apps/backend/chatballs/support/models.py
diff --git a/apps/backend/hub_platform/support/public_urls.py b/apps/backend/chatballs/support/public_urls.py
similarity index 87%
rename from apps/backend/hub_platform/support/public_urls.py
rename to apps/backend/chatballs/support/public_urls.py
index e310b6d..d2e0253 100644
--- a/apps/backend/hub_platform/support/public_urls.py
+++ b/apps/backend/chatballs/support/public_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.support import public_views
+from chatballs.support import public_views
urlpatterns = [
path(
diff --git a/apps/backend/hub_platform/support/public_views.py b/apps/backend/chatballs/support/public_views.py
similarity index 89%
rename from apps/backend/hub_platform/support/public_views.py
rename to apps/backend/chatballs/support/public_views.py
index a7c168e..f53ac01 100644
--- a/apps/backend/hub_platform/support/public_views.py
+++ b/apps/backend/chatballs/support/public_views.py
@@ -4,28 +4,28 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.conversations.models import Conversation
-from hub_platform.conversations.serializers import conversation_payload
-from hub_platform.identity.models import Organization
-from hub_platform.support import errors
-from hub_platform.support.messages import post_support_message, support_messages_since
-from hub_platform.support.serializers import support_identity_snapshot_payload
-from hub_platform.support.session import start_support_session
-from hub_platform.support.token import verify_support_token
-from hub_platform.support.widget_credential import verify_widget_credential
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.ingress import (
+from chatballs.conversations.models import Conversation
+from chatballs.conversations.serializers import conversation_payload
+from chatballs.identity.models import Organization
+from chatballs.support import errors
+from chatballs.support.messages import post_support_message, support_messages_since
+from chatballs.support.serializers import support_identity_snapshot_payload
+from chatballs.support.session import start_support_session
+from chatballs.support.token import verify_support_token
+from chatballs.support.widget_credential import verify_widget_credential
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.ingress import (
support_channel_routes,
support_conversation_route,
web_widget_route,
)
-from hub_platform.webchat.models import (
+from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
)
-from hub_platform.webchat.services import origin_allowed
+from chatballs.webchat.services import origin_allowed
class _Public(APIView):
diff --git a/apps/backend/hub_platform/support/selectors.py b/apps/backend/chatballs/support/selectors.py
similarity index 95%
rename from apps/backend/hub_platform/support/selectors.py
rename to apps/backend/chatballs/support/selectors.py
index 477e2de..3cb4291 100644
--- a/apps/backend/hub_platform/support/selectors.py
+++ b/apps/backend/chatballs/support/selectors.py
@@ -2,12 +2,12 @@ from __future__ import annotations
from django.db.models import QuerySet
-from hub_platform.support.models import (
+from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
def contracts_for_context(context: TenantContext) -> QuerySet[ProductSupportContract]:
diff --git a/apps/backend/hub_platform/support/serializers.py b/apps/backend/chatballs/support/serializers.py
similarity index 95%
rename from apps/backend/hub_platform/support/serializers.py
rename to apps/backend/chatballs/support/serializers.py
index 588f66a..6edf97e 100644
--- a/apps/backend/hub_platform/support/serializers.py
+++ b/apps/backend/chatballs/support/serializers.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from hub_platform.support.models import ProductSupportContract, SupportIdentitySnapshot
+from chatballs.support.models import ProductSupportContract, SupportIdentitySnapshot
def support_contract_payload(contract: ProductSupportContract) -> dict[str, object]:
diff --git a/apps/backend/hub_platform/support/services.py b/apps/backend/chatballs/support/services.py
similarity index 92%
rename from apps/backend/hub_platform/support/services.py
rename to apps/backend/chatballs/support/services.py
index 6a48c23..db5a75d 100644
--- a/apps/backend/hub_platform/support/services.py
+++ b/apps/backend/chatballs/support/services.py
@@ -5,11 +5,11 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.channels.models import Channel
-from hub_platform.identity.models import Organization
-from hub_platform.products.models import Product
-from hub_platform.support.models import ContractStatus, ProductSupportContract
-from hub_platform.tenancy.context import TenantContext
+from chatballs.channels.models import Channel
+from chatballs.identity.models import Organization
+from chatballs.products.models import Product
+from chatballs.support.models import ContractStatus, ProductSupportContract
+from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True)
diff --git a/apps/backend/hub_platform/support/session.py b/apps/backend/chatballs/support/session.py
similarity index 93%
rename from apps/backend/hub_platform/support/session.py
rename to apps/backend/chatballs/support/session.py
index 3b602d5..7667192 100644
--- a/apps/backend/hub_platform/support/session.py
+++ b/apps/backend/chatballs/support/session.py
@@ -13,25 +13,25 @@ from typing import Any
from django.db import transaction
from django.http import HttpRequest
-from hub_platform.conversations.models import (
+from chatballs.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
)
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.models import AuditResult, Organization
-from hub_platform.support import errors
-from hub_platform.support.extract import extract_context, validate_schema
-from hub_platform.support.models import (
+from chatballs.identity.audit import record_audit_event
+from chatballs.identity.models import AuditResult, Organization
+from chatballs.support import errors
+from chatballs.support.extract import extract_context, validate_schema
+from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
-from hub_platform.support.selectors import contract_by_code
-from hub_platform.support.token import TokenClaims, claims_datetimes, verify_support_token
-from hub_platform.support.widget_credential import issue_widget_credential
-from hub_platform.tenancy.context import TenantContext
+from chatballs.support.selectors import contract_by_code
+from chatballs.support.token import TokenClaims, claims_datetimes, verify_support_token
+from chatballs.support.widget_credential import issue_widget_credential
+from chatballs.tenancy.context import TenantContext
def _deny_channel_policy(channel) -> errors.SupportSessionError | None:
diff --git a/apps/backend/hub_platform/support/test_helpers.py b/apps/backend/chatballs/support/test_helpers.py
similarity index 100%
rename from apps/backend/hub_platform/support/test_helpers.py
rename to apps/backend/chatballs/support/test_helpers.py
diff --git a/apps/backend/hub_platform/support/test_widget_messages.py b/apps/backend/chatballs/support/test_widget_messages.py
similarity index 91%
rename from apps/backend/hub_platform/support/test_widget_messages.py
rename to apps/backend/chatballs/support/test_widget_messages.py
index d4052ac..2530984 100644
--- a/apps/backend/hub_platform/support/test_widget_messages.py
+++ b/apps/backend/chatballs/support/test_widget_messages.py
@@ -5,15 +5,15 @@ from __future__ import annotations
import json
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.products.models import Product
-from hub_platform.support.models import ContractStatus, ProductSupportContract
-from hub_platform.support.test_helpers import FOXRAY_DATA, make_support_token
-from hub_platform.webchat.testing import create_web_widget
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.products.models import Product
+from chatballs.support.models import ContractStatus, ProductSupportContract
+from chatballs.support.test_helpers import FOXRAY_DATA, make_support_token
+from chatballs.webchat.testing import create_web_widget
SECRET = "test-support-secret-very-long-32bytes!!"
diff --git a/apps/backend/hub_platform/support/tests.py b/apps/backend/chatballs/support/tests.py
similarity index 95%
rename from apps/backend/hub_platform/support/tests.py
rename to apps/backend/chatballs/support/tests.py
index 61667c0..9827219 100644
--- a/apps/backend/hub_platform/support/tests.py
+++ b/apps/backend/chatballs/support/tests.py
@@ -3,21 +3,21 @@ from __future__ import annotations
import json
from django.test import TestCase
-from hub_platform.testing import TenantAPIClient as APIClient
+from chatballs.testing import TenantAPIClient as APIClient
-from hub_platform.channels.models import Channel
-from hub_platform.identity.audit import AuditResult
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import AuditEvent, Organization
-from hub_platform.products.models import Product
-from hub_platform.support.models import (
+from chatballs.channels.models import Channel
+from chatballs.identity.audit import AuditResult
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import AuditEvent, Organization
+from chatballs.products.models import Product
+from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
-from hub_platform.support.test_helpers import FOXRAY_DATA, make_support_token
-from hub_platform.webchat.models import WebChatWidgetMode
-from hub_platform.webchat.testing import create_web_widget
+from chatballs.support.test_helpers import FOXRAY_DATA, make_support_token
+from chatballs.webchat.models import WebChatWidgetMode
+from chatballs.webchat.testing import create_web_widget
SECRET = "test-support-secret-very-long-32bytes!!"
diff --git a/apps/backend/hub_platform/support/token.py b/apps/backend/chatballs/support/token.py
similarity index 99%
rename from apps/backend/hub_platform/support/token.py
rename to apps/backend/chatballs/support/token.py
index 65da04d..9c81e5b 100644
--- a/apps/backend/hub_platform/support/token.py
+++ b/apps/backend/chatballs/support/token.py
@@ -19,7 +19,7 @@ from dataclasses import dataclass
from datetime import UTC
from typing import Any
-from hub_platform.support import errors
+from chatballs.support import errors
SUPPORT_AUDIENCE = "edevshub.support"
diff --git a/apps/backend/hub_platform/support/urls.py b/apps/backend/chatballs/support/urls.py
similarity index 84%
rename from apps/backend/hub_platform/support/urls.py
rename to apps/backend/chatballs/support/urls.py
index d4c13c4..0e7ae64 100644
--- a/apps/backend/hub_platform/support/urls.py
+++ b/apps/backend/chatballs/support/urls.py
@@ -1,9 +1,9 @@
from django.urls import include, path
-from hub_platform.support import views
+from chatballs.support import views
urlpatterns = [
- path("portals/", include("hub_platform.support_portals.urls")),
+ path("portals/", include("chatballs.support_portals.urls")),
path("contracts/", views.SupportContractListView.as_view(), name="support-contract-list"),
path(
"contracts//",
diff --git a/apps/backend/hub_platform/support/views.py b/apps/backend/chatballs/support/views.py
similarity index 91%
rename from apps/backend/hub_platform/support/views.py
rename to apps/backend/chatballs/support/views.py
index 4c462c6..d590dc4 100644
--- a/apps/backend/hub_platform/support/views.py
+++ b/apps/backend/chatballs/support/views.py
@@ -3,15 +3,15 @@ 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.audit import record_audit_event
-from hub_platform.support.models import ProductSupportContract
-from hub_platform.support.selectors import contract_for_context, contracts_for_context
-from hub_platform.support.serializers import (
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
+from chatballs.support.models import ProductSupportContract
+from chatballs.support.selectors import contract_for_context, contracts_for_context
+from chatballs.support.serializers import (
support_contract_payload,
support_identity_snapshot_payload,
)
-from hub_platform.support.services import ContractInput, register_contract, set_contract_status
+from chatballs.support.services import ContractInput, register_contract, set_contract_status
def _validation_error(error: ValidationError) -> Response:
@@ -124,13 +124,13 @@ class SupportSnapshotsBySubjectView(APIView):
required_capability = "support.view"
def get(self, request: Request) -> Response:
- from hub_platform.support.selectors import snapshots_for_subject
+ from chatballs.support.selectors import snapshots_for_subject
product_code = request.query_params.get("product")
subject_key = request.query_params.get("subject")
if not product_code or not subject_key:
return Response({"detail": "product и subject обязательны"}, status=400)
- from hub_platform.products.models import Product
+ from chatballs.products.models import Product
product = Product.objects.filter(
organization=request.tenant_context.organization, code=product_code
diff --git a/apps/backend/hub_platform/support/widget_credential.py b/apps/backend/chatballs/support/widget_credential.py
similarity index 100%
rename from apps/backend/hub_platform/support/widget_credential.py
rename to apps/backend/chatballs/support/widget_credential.py
diff --git a/apps/backend/hub_platform/products/migrations/__init__.py b/apps/backend/chatballs/support_portals/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/products/migrations/__init__.py
rename to apps/backend/chatballs/support_portals/__init__.py
diff --git a/apps/backend/hub_platform/support_portals/addressing.py b/apps/backend/chatballs/support_portals/addressing.py
similarity index 92%
rename from apps/backend/hub_platform/support_portals/addressing.py
rename to apps/backend/chatballs/support_portals/addressing.py
index e72fb84..1383e32 100644
--- a/apps/backend/hub_platform/support_portals/addressing.py
+++ b/apps/backend/chatballs/support_portals/addressing.py
@@ -25,7 +25,7 @@ def validate_domain(value: str) -> str:
def hosted_domain(portal_key: str) -> str:
- base_domain = normalize_domain(settings.CUS_HELP_BASE_DOMAIN)
+ base_domain = normalize_domain(settings.CHATBALLS_HELP_BASE_DOMAIN)
return validate_domain(f"{portal_key}.{base_domain}")
@@ -37,7 +37,7 @@ def clean_portal_domains(portal) -> None:
portal.custom_domain = validate_domain(portal.custom_domain)
application_hosts = {
normalize_domain(value.lstrip("."))
- for value in getattr(settings, "CUS_APP_PRIMARY_HOSTS", [])
+ for value in getattr(settings, "CHATBALLS_APP_PRIMARY_HOSTS", [])
if value and not value.startswith(".")
}
if portal.custom_domain in application_hosts:
@@ -69,8 +69,8 @@ def clean_portal_domains(portal) -> None:
def portal_public_url(*, hosted: str, custom: str = "", custom_verified: bool = False) -> str:
host = custom if custom and custom_verified else hosted
- scheme = "https" if custom and custom_verified else settings.CUS_HELP_PUBLIC_SCHEME
- port = "" if custom and custom_verified else settings.CUS_HELP_PUBLIC_PORT
+ scheme = "https" if custom and custom_verified else settings.CHATBALLS_HELP_PUBLIC_SCHEME
+ port = "" if custom and custom_verified else settings.CHATBALLS_HELP_PUBLIC_PORT
return f"{scheme}://{host}{f':{port}' if port else ''}"
diff --git a/apps/backend/hub_platform/support_portals/admin.py b/apps/backend/chatballs/support_portals/admin.py
similarity index 88%
rename from apps/backend/hub_platform/support_portals/admin.py
rename to apps/backend/chatballs/support_portals/admin.py
index e857c7c..20a7829 100644
--- a/apps/backend/hub_platform/support_portals/admin.py
+++ b/apps/backend/chatballs/support_portals/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
-from hub_platform.support_portals.models import (
+from chatballs.support_portals.models import (
PortalArticle,
PortalArticleFeedback,
PortalArticleRevision,
diff --git a/apps/backend/hub_platform/support_portals/api.py b/apps/backend/chatballs/support_portals/api.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/api.py
rename to apps/backend/chatballs/support_portals/api.py
diff --git a/apps/backend/hub_platform/support_portals/apps.py b/apps/backend/chatballs/support_portals/apps.py
similarity index 75%
rename from apps/backend/hub_platform/support_portals/apps.py
rename to apps/backend/chatballs/support_portals/apps.py
index 95d7d78..e0f4378 100644
--- a/apps/backend/hub_platform/support_portals/apps.py
+++ b/apps/backend/chatballs/support_portals/apps.py
@@ -3,4 +3,4 @@ from django.apps import AppConfig
class SupportPortalsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.support_portals"
+ name = "chatballs.support_portals"
diff --git a/apps/backend/hub_platform/support_portals/article_import.py b/apps/backend/chatballs/support_portals/article_import.py
similarity index 96%
rename from apps/backend/hub_platform/support_portals/article_import.py
rename to apps/backend/chatballs/support_portals/article_import.py
index 39f0150..5208757 100644
--- a/apps/backend/hub_platform/support_portals/article_import.py
+++ b/apps/backend/chatballs/support_portals/article_import.py
@@ -3,14 +3,14 @@ from dataclasses import dataclass, field
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.support_portals.content_services import (
+from chatballs.support_portals.content_services import (
add_revision,
create_article,
ensure_portal_editable,
update_article,
)
-from hub_platform.support_portals.models import PortalCategory
-from hub_platform.tenancy.context import TenantContext
+from chatballs.support_portals.models import PortalCategory
+from chatballs.tenancy.context import TenantContext
@dataclass(slots=True)
diff --git a/apps/backend/hub_platform/support_portals/article_import_views.py b/apps/backend/chatballs/support_portals/article_import_views.py
similarity index 84%
rename from apps/backend/hub_platform/support_portals/article_import_views.py
rename to apps/backend/chatballs/support_portals/article_import_views.py
index 7f6733e..a854b3d 100644
--- a/apps/backend/hub_platform/support_portals/article_import_views.py
+++ b/apps/backend/chatballs/support_portals/article_import_views.py
@@ -1,9 +1,9 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.support_portals.article_import import import_articles
-from hub_platform.support_portals.portal_views import PortalBaseView
+from chatballs.identity.audit import record_audit_event
+from chatballs.support_portals.article_import import import_articles
+from chatballs.support_portals.portal_views import PortalBaseView
class ArticleImportView(PortalBaseView):
diff --git a/apps/backend/hub_platform/support_portals/content_services.py b/apps/backend/chatballs/support_portals/content_services.py
similarity index 96%
rename from apps/backend/hub_platform/support_portals/content_services.py
rename to apps/backend/chatballs/support_portals/content_services.py
index df395fa..f920f9d 100644
--- a/apps/backend/hub_platform/support_portals/content_services.py
+++ b/apps/backend/chatballs/support_portals/content_services.py
@@ -4,16 +4,16 @@ from django.db.models import Max
from django.utils import timezone
from django.utils.text import slugify
-from hub_platform.ai.indexing import reindex_portal_article
-from hub_platform.support_portals.models import (
+from chatballs.ai.indexing import reindex_portal_article
+from chatballs.support_portals.models import (
PortalArticle,
PortalArticleFeedback,
PortalArticleRevision,
PortalCategory,
SupportPortal,
)
-from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
-from hub_platform.tenancy.context import TenantContext
+from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
+from chatballs.tenancy.context import TenantContext
def create_category(
diff --git a/apps/backend/hub_platform/support_portals/content_views.py b/apps/backend/chatballs/support_portals/content_views.py
similarity index 95%
rename from apps/backend/hub_platform/support_portals/content_views.py
rename to apps/backend/chatballs/support_portals/content_views.py
index bbc855e..2b0bb74 100644
--- a/apps/backend/hub_platform/support_portals/content_views.py
+++ b/apps/backend/chatballs/support_portals/content_views.py
@@ -2,8 +2,8 @@ from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
-from hub_platform.support_portals.api import validation_response
-from hub_platform.support_portals.content_services import (
+from chatballs.support_portals.api import validation_response
+from chatballs.support_portals.content_services import (
add_revision,
archive_article,
create_article,
@@ -13,14 +13,14 @@ from hub_platform.support_portals.content_services import (
update_category,
update_article,
)
-from hub_platform.support_portals.models import PortalArticle
-from hub_platform.support_portals.portal_views import PortalBaseView
-from hub_platform.support_portals.serializers import (
+from chatballs.support_portals.models import PortalArticle
+from chatballs.support_portals.portal_views import PortalBaseView
+from chatballs.support_portals.serializers import (
article_payload,
category_payload,
revision_payload,
)
-from hub_platform.support_portals.selectors import category_article_counts
+from chatballs.support_portals.selectors import category_article_counts
def _article(portal, article_id: int) -> PortalArticle | None:
diff --git a/apps/backend/hub_platform/support_portals/domain_services.py b/apps/backend/chatballs/support_portals/domain_services.py
similarity index 86%
rename from apps/backend/hub_platform/support_portals/domain_services.py
rename to apps/backend/chatballs/support_portals/domain_services.py
index b660ac7..f94910e 100644
--- a/apps/backend/hub_platform/support_portals/domain_services.py
+++ b/apps/backend/chatballs/support_portals/domain_services.py
@@ -8,17 +8,17 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
-from hub_platform.support_portals.addressing import normalize_domain, validate_domain
-from hub_platform.support_portals.models import SupportPortal
-from hub_platform.support_portals.statuses import PortalStatus
+from chatballs.support_portals.addressing import normalize_domain, validate_domain
+from chatballs.support_portals.models import SupportPortal
+from chatballs.support_portals.statuses import PortalStatus
def domain_verification_name(portal: SupportPortal) -> str:
- return f"_custocrm.{portal.custom_domain}" if portal.custom_domain else ""
+ return f"_chatballs.{portal.custom_domain}" if portal.custom_domain else ""
def domain_verification_value(portal: SupportPortal) -> str:
- return f"custocrm-verification={portal.custom_domain_verification_token}"
+ return f"chatballs-verification={portal.custom_domain_verification_token}"
def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal:
@@ -49,7 +49,7 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
)
if not portal.custom_domain:
raise ValidationError({"customDomain": "Сначала укажите домен"})
- if settings.CUS_HELP_PUBLIC_IPV4:
+ if settings.CHATBALLS_HELP_PUBLIC_IPV4:
try:
address_answers = dns.resolver.resolve(portal.custom_domain, "A")
addresses = {
@@ -65,7 +65,7 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
raise ValidationError(
{"customDomain": "A-запись домена пока не найдена"}
) from error
- if settings.CUS_HELP_PUBLIC_IPV4 not in addresses:
+ if settings.CHATBALLS_HELP_PUBLIC_IPV4 not in addresses:
raise ValidationError(
{"customDomain": "A-запись домена указывает не на сервер Chatballs"}
)
diff --git a/apps/backend/hub_platform/support_portals/gateway_views.py b/apps/backend/chatballs/support_portals/gateway_views.py
similarity index 82%
rename from apps/backend/hub_platform/support_portals/gateway_views.py
rename to apps/backend/chatballs/support_portals/gateway_views.py
index 8f30d28..b9cc657 100644
--- a/apps/backend/hub_platform/support_portals/gateway_views.py
+++ b/apps/backend/chatballs/support_portals/gateway_views.py
@@ -3,8 +3,8 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.support_portals.addressing import normalize_domain
-from hub_platform.tenancy.ingress import support_portal_route
+from chatballs.support_portals.addressing import normalize_domain
+from chatballs.tenancy.ingress import support_portal_route
class HelpDomainAuthorizationView(APIView):
diff --git a/apps/backend/hub_platform/support_portals/host_boundary.py b/apps/backend/chatballs/support_portals/host_boundary.py
similarity index 88%
rename from apps/backend/hub_platform/support_portals/host_boundary.py
rename to apps/backend/chatballs/support_portals/host_boundary.py
index 086f8a3..63497cf 100644
--- a/apps/backend/hub_platform/support_portals/host_boundary.py
+++ b/apps/backend/chatballs/support_portals/host_boundary.py
@@ -6,8 +6,8 @@ from django.conf import settings
from django.db import connections
from django.http import HttpRequest, HttpResponseBadRequest
-from hub_platform.support_portals.addressing import normalize_domain
-from hub_platform.tenancy.ingress import support_portal_route
+from chatballs.support_portals.addressing import normalize_domain
+from chatballs.tenancy.ingress import support_portal_route
class SupportPortalHostBoundaryMiddleware:
@@ -31,7 +31,7 @@ class SupportPortalHostBoundaryMiddleware:
or str(connections["default"].settings_dict["NAME"]).startswith("test_")
):
return True
- for allowed in settings.CUS_APP_PRIMARY_HOSTS:
+ for allowed in settings.CHATBALLS_APP_PRIMARY_HOSTS:
normalized = normalize_domain(allowed.lstrip("."))
if host == normalized or (
allowed.startswith(".") and host.endswith(f".{normalized}")
diff --git a/apps/backend/hub_platform/support_portals/migrations/0001_portals.py b/apps/backend/chatballs/support_portals/migrations/0001_portals.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0001_portals.py
rename to apps/backend/chatballs/support_portals/migrations/0001_portals.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/0002_content_and_products.py b/apps/backend/chatballs/support_portals/migrations/0002_content_and_products.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0002_content_and_products.py
rename to apps/backend/chatballs/support_portals/migrations/0002_content_and_products.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/0003_category_description.py b/apps/backend/chatballs/support_portals/migrations/0003_category_description.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0003_category_description.py
rename to apps/backend/chatballs/support_portals/migrations/0003_category_description.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py b/apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py
similarity index 95%
rename from apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py
rename to apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py
index ee8322a..7815966 100644
--- a/apps/backend/hub_platform/support_portals/migrations/0004_portal_domains.py
+++ b/apps/backend/chatballs/support_portals/migrations/0004_portal_domains.py
@@ -5,7 +5,7 @@ from django.db.models import Q
def fill_hosted_domains(apps, schema_editor):
SupportPortal = apps.get_model("support_portals", "SupportPortal")
- base_domain = settings.CUS_HELP_BASE_DOMAIN.strip().lower().rstrip(".")
+ base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".")
for portal in SupportPortal.objects.all().only("id", "slug"):
SupportPortal.objects.filter(id=portal.id).update(
hosted_domain=f"{portal.slug}.{base_domain}"
diff --git a/apps/backend/hub_platform/support_portals/migrations/0005_custom_domain_verification_token.py b/apps/backend/chatballs/support_portals/migrations/0005_custom_domain_verification_token.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0005_custom_domain_verification_token.py
rename to apps/backend/chatballs/support_portals/migrations/0005_custom_domain_verification_token.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/0006_support_portal_widget_channel.py b/apps/backend/chatballs/support_portals/migrations/0006_support_portal_widget_channel.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0006_support_portal_widget_channel.py
rename to apps/backend/chatballs/support_portals/migrations/0006_support_portal_widget_channel.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/0007_portal_widget_references.py b/apps/backend/chatballs/support_portals/migrations/0007_portal_widget_references.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0007_portal_widget_references.py
rename to apps/backend/chatballs/support_portals/migrations/0007_portal_widget_references.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/0008_remove_supportportal_department.py b/apps/backend/chatballs/support_portals/migrations/0008_remove_supportportal_department.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/0008_remove_supportportal_department.py
rename to apps/backend/chatballs/support_portals/migrations/0008_remove_supportportal_department.py
diff --git a/apps/backend/hub_platform/support_portals/__init__.py b/apps/backend/chatballs/support_portals/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/__init__.py
rename to apps/backend/chatballs/support_portals/migrations/__init__.py
diff --git a/apps/backend/hub_platform/support_portals/models.py b/apps/backend/chatballs/support_portals/models.py
similarity index 95%
rename from apps/backend/hub_platform/support_portals/models.py
rename to apps/backend/chatballs/support_portals/models.py
index 9b06dab..cb0a5dc 100644
--- a/apps/backend/hub_platform/support_portals/models.py
+++ b/apps/backend/chatballs/support_portals/models.py
@@ -4,8 +4,8 @@ import uuid
from django.core.exceptions import ValidationError
from django.db import models
-from hub_platform.tenancy.models import TenantRelationModel
-from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
+from chatballs.tenancy.models import TenantRelationModel
+from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
class SupportPortal(TenantRelationModel):
@@ -71,8 +71,8 @@ class SupportPortal(TenantRelationModel):
def clean(self) -> None:
super().clean()
- from hub_platform.support_portals.addressing import clean_portal_domains
- from hub_platform.support_portals.widget_validation import validate_portal_widget
+ from chatballs.support_portals.addressing import clean_portal_domains
+ from chatballs.support_portals.widget_validation import validate_portal_widget
clean_portal_domains(self)
validate_portal_widget(self)
@@ -119,7 +119,7 @@ class SupportPortalProduct(TenantRelationModel):
def clean(self) -> None:
super().clean()
- from hub_platform.support_portals.widget_validation import (
+ from chatballs.support_portals.widget_validation import (
validate_product_support_widget,
)
diff --git a/apps/backend/hub_platform/support_portals/portal_services.py b/apps/backend/chatballs/support_portals/portal_services.py
similarity index 94%
rename from apps/backend/hub_platform/support_portals/portal_services.py
rename to apps/backend/chatballs/support_portals/portal_services.py
index c98a811..e40bb31 100644
--- a/apps/backend/hub_platform/support_portals/portal_services.py
+++ b/apps/backend/chatballs/support_portals/portal_services.py
@@ -4,17 +4,17 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
-from hub_platform.channels.models import Channel
-from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
-from hub_platform.products.models import Product
-from hub_platform.support_portals.addressing import hosted_domain
-from hub_platform.support_portals.models import (
+from chatballs.channels.models import Channel
+from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
+from chatballs.products.models import Product
+from chatballs.support_portals.addressing import hosted_domain
+from chatballs.support_portals.models import (
SupportPortal,
SupportPortalProduct,
)
-from hub_platform.support_portals.statuses import PortalStatus
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.webchat.models import (
+from chatballs.support_portals.statuses import PortalStatus
+from chatballs.tenancy.context import TenantContext
+from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
diff --git a/apps/backend/hub_platform/support_portals/portal_views.py b/apps/backend/chatballs/support_portals/portal_views.py
similarity index 92%
rename from apps/backend/hub_platform/support_portals/portal_views.py
rename to apps/backend/chatballs/support_portals/portal_views.py
index b57e9f3..573a793 100644
--- a/apps/backend/hub_platform/support_portals/portal_views.py
+++ b/apps/backend/chatballs/support_portals/portal_views.py
@@ -4,30 +4,30 @@ 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.audit import record_audit_event
-from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
-from hub_platform.support_portals.api import validation_response
-from hub_platform.support_portals.models import SupportPortal
-from hub_platform.support_portals.domain_services import (
+from chatballs.api.permissions import HasCapability
+from chatballs.identity.audit import record_audit_event
+from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
+from chatballs.support_portals.api import validation_response
+from chatballs.support_portals.models import SupportPortal
+from chatballs.support_portals.domain_services import (
set_custom_domain,
verify_custom_domain,
)
-from hub_platform.support_portals.portal_services import (
+from chatballs.support_portals.portal_services import (
PortalInput,
create_portal,
replace_product_links,
set_portal_status,
update_portal,
)
-from hub_platform.support_portals.selectors import portal_for_context, portals_for_context
-from hub_platform.support_portals.serializers import portal_payload
-from hub_platform.webchat.models import (
+from chatballs.support_portals.selectors import portal_for_context, portals_for_context
+from chatballs.support_portals.serializers import portal_payload
+from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
)
-from hub_platform.webchat.widgets import widget_payload
+from chatballs.webchat.widgets import widget_payload
def _optional_id(value) -> int | None:
@@ -107,9 +107,9 @@ class PortalListView(PortalBaseView):
"used": active_count,
},
"address": {
- "scheme": settings.CUS_HELP_PUBLIC_SCHEME,
- "baseDomain": settings.CUS_HELP_BASE_DOMAIN,
- "port": settings.CUS_HELP_PUBLIC_PORT or None,
+ "scheme": settings.CHATBALLS_HELP_PUBLIC_SCHEME,
+ "baseDomain": settings.CHATBALLS_HELP_BASE_DOMAIN,
+ "port": settings.CHATBALLS_HELP_PUBLIC_PORT or None,
},
}
)
diff --git a/apps/backend/hub_platform/support_portals/public_urls.py b/apps/backend/chatballs/support_portals/public_urls.py
similarity index 90%
rename from apps/backend/hub_platform/support_portals/public_urls.py
rename to apps/backend/chatballs/support_portals/public_urls.py
index 684497b..666dacb 100644
--- a/apps/backend/hub_platform/support_portals/public_urls.py
+++ b/apps/backend/chatballs/support_portals/public_urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.support_portals import public_views
+from chatballs.support_portals import public_views
urlpatterns = [
path("", public_views.PublicPortalDetailView.as_view(), name="public-support-portal"),
diff --git a/apps/backend/hub_platform/support_portals/public_views.py b/apps/backend/chatballs/support_portals/public_views.py
similarity index 92%
rename from apps/backend/hub_platform/support_portals/public_views.py
rename to apps/backend/chatballs/support_portals/public_views.py
index 77e7a60..16807d8 100644
--- a/apps/backend/hub_platform/support_portals/public_views.py
+++ b/apps/backend/chatballs/support_portals/public_views.py
@@ -4,18 +4,18 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.throttling import ScopedRateThrottle
-from hub_platform.identity.models import Organization
-from hub_platform.support_portals.content_services import record_feedback
-from hub_platform.support_portals.models import SupportPortal
-from hub_platform.support_portals.selectors import category_article_counts, public_articles
-from hub_platform.support_portals.serializers import (
+from chatballs.identity.models import Organization
+from chatballs.support_portals.content_services import record_feedback
+from chatballs.support_portals.models import SupportPortal
+from chatballs.support_portals.selectors import category_article_counts, public_articles
+from chatballs.support_portals.serializers import (
category_payload,
public_article_payload,
public_portal_payload,
)
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.ingress import support_portal_route
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.ingress import support_portal_route
class PublicPortalView(APIView):
diff --git a/apps/backend/hub_platform/support_portals/selectors.py b/apps/backend/chatballs/support_portals/selectors.py
similarity index 96%
rename from apps/backend/hub_platform/support_portals/selectors.py
rename to apps/backend/chatballs/support_portals/selectors.py
index c53c9a2..bad24ef 100644
--- a/apps/backend/hub_platform/support_portals/selectors.py
+++ b/apps/backend/chatballs/support_portals/selectors.py
@@ -1,7 +1,7 @@
from django.db.models import Count, Q, QuerySet
-from hub_platform.support_portals.models import PortalArticle, SupportPortal
-from hub_platform.tenancy.context import TenantContext
+from chatballs.support_portals.models import PortalArticle, SupportPortal
+from chatballs.tenancy.context import TenantContext
def portals_for_context(context: TenantContext) -> QuerySet[SupportPortal]:
diff --git a/apps/backend/hub_platform/support_portals/serializers.py b/apps/backend/chatballs/support_portals/serializers.py
similarity index 94%
rename from apps/backend/hub_platform/support_portals/serializers.py
rename to apps/backend/chatballs/support_portals/serializers.py
index 4956d4f..71f8682 100644
--- a/apps/backend/hub_platform/support_portals/serializers.py
+++ b/apps/backend/chatballs/support_portals/serializers.py
@@ -1,15 +1,15 @@
from django.conf import settings
-from hub_platform.support_portals.models import (
+from chatballs.support_portals.models import (
PortalArticle,
PortalArticleRevision,
PortalCategory,
SupportPortal,
SupportPortalProduct,
)
-from hub_platform.integrations.models import IntegrationProvider, IntegrationStatus
-from hub_platform.support_portals.addressing import portal_public_url
-from hub_platform.support_portals.domain_services import (
+from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
+from chatballs.support_portals.addressing import portal_public_url
+from chatballs.support_portals.domain_services import (
domain_verification_name,
domain_verification_value,
)
@@ -47,9 +47,9 @@ def portal_payload(portal: SupportPortal) -> dict:
{
"name": portal.custom_domain,
"type": "A",
- "value": settings.CUS_HELP_PUBLIC_IPV4,
+ "value": settings.CHATBALLS_HELP_PUBLIC_IPV4,
}
- if portal.custom_domain and settings.CUS_HELP_PUBLIC_IPV4
+ if portal.custom_domain and settings.CHATBALLS_HELP_PUBLIC_IPV4
else None
),
"customDomainVerifiedAt": portal.custom_domain_verified_at,
diff --git a/apps/backend/hub_platform/support_portals/statuses.py b/apps/backend/chatballs/support_portals/statuses.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/statuses.py
rename to apps/backend/chatballs/support_portals/statuses.py
diff --git a/apps/backend/hub_platform/support_portals/migrations/__init__.py b/apps/backend/chatballs/support_portals/tests/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/migrations/__init__.py
rename to apps/backend/chatballs/support_portals/tests/__init__.py
diff --git a/apps/backend/hub_platform/support_portals/tests/base.py b/apps/backend/chatballs/support_portals/tests/base.py
similarity index 85%
rename from apps/backend/hub_platform/support_portals/tests/base.py
rename to apps/backend/chatballs/support_portals/tests/base.py
index 409a471..abb60f5 100644
--- a/apps/backend/hub_platform/support_portals/tests/base.py
+++ b/apps/backend/chatballs/support_portals/tests/base.py
@@ -1,9 +1,9 @@
from django.test import TestCase
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.products.models import Product
-from hub_platform.testing import TenantAPIClient
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.products.models import Product
+from chatballs.testing import TenantAPIClient
class SupportPortalTestCase(TestCase):
diff --git a/apps/backend/hub_platform/support_portals/tests/test_article_import.py b/apps/backend/chatballs/support_portals/tests/test_article_import.py
similarity index 97%
rename from apps/backend/hub_platform/support_portals/tests/test_article_import.py
rename to apps/backend/chatballs/support_portals/tests/test_article_import.py
index 11b7bb1..7649509 100644
--- a/apps/backend/hub_platform/support_portals/tests/test_article_import.py
+++ b/apps/backend/chatballs/support_portals/tests/test_article_import.py
@@ -1,5 +1,5 @@
-from hub_platform.support_portals.models import PortalArticle
-from hub_platform.support_portals.tests.base import SupportPortalTestCase
+from chatballs.support_portals.models import PortalArticle
+from chatballs.support_portals.tests.base import SupportPortalTestCase
class SupportPortalArticleImportTests(SupportPortalTestCase):
diff --git a/apps/backend/hub_platform/support_portals/tests/test_management_api.py b/apps/backend/chatballs/support_portals/tests/test_management_api.py
similarity index 92%
rename from apps/backend/hub_platform/support_portals/tests/test_management_api.py
rename to apps/backend/chatballs/support_portals/tests/test_management_api.py
index 337d138..84f6def 100644
--- a/apps/backend/hub_platform/support_portals/tests/test_management_api.py
+++ b/apps/backend/chatballs/support_portals/tests/test_management_api.py
@@ -3,16 +3,16 @@ from unittest import mock
from django.conf import settings
from django.test import override_settings
-from hub_platform.channels.models import Channel
-from hub_platform.identity.models import (
+from chatballs.channels.models import Channel
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.support_portals.models import SupportPortal, SupportPortalProduct
-from hub_platform.support_portals.tests.base import SupportPortalTestCase
-from hub_platform.webchat.testing import create_web_widget
+from chatballs.support_portals.models import SupportPortal, SupportPortalProduct
+from chatballs.support_portals.tests.base import SupportPortalTestCase
+from chatballs.webchat.testing import create_web_widget
class SupportPortalManagementTests(SupportPortalTestCase):
@@ -78,10 +78,10 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertEqual(first.status_code, 201, first.content)
self.assertEqual(
first.json()["portal"]["hostedDomain"],
- f"foxray-help.{settings.CUS_HELP_BASE_DOMAIN}",
+ f"foxray-help.{settings.CHATBALLS_HELP_BASE_DOMAIN}",
)
self.assertTrue(first.json()["portal"]["publicUrl"].endswith(
- f"foxray-help.{settings.CUS_HELP_BASE_DOMAIN}"
+ f"foxray-help.{settings.CHATBALLS_HELP_BASE_DOMAIN}"
))
listed = self.client.get("/api/v1/support/portals/")
@@ -139,7 +139,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
link.refresh_from_db()
self.assertEqual(link.support_channel, self.channel)
- @override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
+ @override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42")
def test_custom_domain_requires_matching_dns_records(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
configured = self.client.put(
@@ -179,11 +179,11 @@ class SupportPortalManagementTests(SupportPortalTestCase):
resolve.call_args_list,
[
mock.call("help.customer.example", "A"),
- mock.call("_custocrm.help.customer.example", "TXT"),
+ mock.call("_chatballs.help.customer.example", "TXT"),
],
)
- @override_settings(CUS_HELP_PUBLIC_IPV4="203.0.113.42")
+ @override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42")
def test_custom_domain_rejects_wrong_address_record(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
self.client.put(
@@ -205,7 +205,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertEqual(response.status_code, 400, response.content)
self.assertIn("указывает не на сервер Chatballs", str(response.json()))
- @override_settings(CUS_APP_PRIMARY_HOSTS=["app.customer.example"])
+ @override_settings(CHATBALLS_APP_PRIMARY_HOSTS=["app.customer.example"])
def test_custom_domain_cannot_shadow_application_host(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
response = self.client.put(
diff --git a/apps/backend/hub_platform/support_portals/tests/test_management_content_api.py b/apps/backend/chatballs/support_portals/tests/test_management_content_api.py
similarity index 97%
rename from apps/backend/hub_platform/support_portals/tests/test_management_content_api.py
rename to apps/backend/chatballs/support_portals/tests/test_management_content_api.py
index 3463611..ab86a97 100644
--- a/apps/backend/hub_platform/support_portals/tests/test_management_content_api.py
+++ b/apps/backend/chatballs/support_portals/tests/test_management_content_api.py
@@ -1,5 +1,5 @@
-from hub_platform.support_portals.tests.base import SupportPortalTestCase
-from hub_platform.webchat.testing import create_web_widget
+from chatballs.support_portals.tests.base import SupportPortalTestCase
+from chatballs.webchat.testing import create_web_widget
class SupportPortalContentManagementTests(SupportPortalTestCase):
diff --git a/apps/backend/hub_platform/support_portals/tests/test_public_api.py b/apps/backend/chatballs/support_portals/tests/test_public_api.py
similarity index 94%
rename from apps/backend/hub_platform/support_portals/tests/test_public_api.py
rename to apps/backend/chatballs/support_portals/tests/test_public_api.py
index 5f36223..91e4487 100644
--- a/apps/backend/hub_platform/support_portals/tests/test_public_api.py
+++ b/apps/backend/chatballs/support_portals/tests/test_public_api.py
@@ -1,11 +1,11 @@
from django.test import TestCase, override_settings
from django.utils import timezone
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.channels.models import Channel
-from hub_platform.support_portals.models import PortalArticleFeedback, SupportPortal
-from hub_platform.testing import TenantAPIClient
-from hub_platform.webchat.testing import create_web_widget
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.channels.models import Channel
+from chatballs.support_portals.models import PortalArticleFeedback, SupportPortal
+from chatballs.testing import TenantAPIClient
+from chatballs.webchat.testing import create_web_widget
class PublicSupportPortalTests(TestCase):
@@ -148,7 +148,7 @@ class PublicSupportPortalTests(TestCase):
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["portal"]["name"], "Edevs Help")
- @override_settings(ROOT_URLCONF="hub_backend.urls_platform")
+ @override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
custom_domain = "help.foxray.example"
SupportPortal.objects.filter(id=self.portal_id).update(
diff --git a/apps/backend/hub_platform/support_portals/urls.py b/apps/backend/chatballs/support_portals/urls.py
similarity index 95%
rename from apps/backend/hub_platform/support_portals/urls.py
rename to apps/backend/chatballs/support_portals/urls.py
index 5de3c52..e5a707b 100644
--- a/apps/backend/hub_platform/support_portals/urls.py
+++ b/apps/backend/chatballs/support_portals/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.support_portals import article_import_views, content_views, portal_views
+from chatballs.support_portals import article_import_views, content_views, portal_views
urlpatterns = [
path("", portal_views.PortalListView.as_view(), name="support-portal-list"),
diff --git a/apps/backend/hub_platform/support_portals/widget_validation.py b/apps/backend/chatballs/support_portals/widget_validation.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/widget_validation.py
rename to apps/backend/chatballs/support_portals/widget_validation.py
diff --git a/apps/backend/hub_platform/tenancy/__init__.py b/apps/backend/chatballs/tenancy/__init__.py
similarity index 61%
rename from apps/backend/hub_platform/tenancy/__init__.py
rename to apps/backend/chatballs/tenancy/__init__.py
index f2997dc..a9e287e 100644
--- a/apps/backend/hub_platform/tenancy/__init__.py
+++ b/apps/backend/chatballs/tenancy/__init__.py
@@ -1,5 +1,5 @@
"""Explicit tenant context for organization-owned operations."""
-from hub_platform.tenancy.context import TenantActorKind, TenantContext
+from chatballs.tenancy.context import TenantActorKind, TenantContext
__all__ = ["TenantActorKind", "TenantContext"]
diff --git a/apps/backend/hub_platform/tenancy/apps.py b/apps/backend/chatballs/tenancy/apps.py
similarity index 78%
rename from apps/backend/hub_platform/tenancy/apps.py
rename to apps/backend/chatballs/tenancy/apps.py
index aef7f1d..edf7d1e 100644
--- a/apps/backend/hub_platform/tenancy/apps.py
+++ b/apps/backend/chatballs/tenancy/apps.py
@@ -3,4 +3,4 @@ from django.apps import AppConfig
class TenancyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "hub_platform.tenancy"
+ name = "chatballs.tenancy"
diff --git a/apps/backend/hub_platform/tenancy/context.py b/apps/backend/chatballs/tenancy/context.py
similarity index 91%
rename from apps/backend/hub_platform/tenancy/context.py
rename to apps/backend/chatballs/tenancy/context.py
index bf04cb7..d6f7723 100644
--- a/apps/backend/hub_platform/tenancy/context.py
+++ b/apps/backend/chatballs/tenancy/context.py
@@ -4,10 +4,10 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING
-from hub_platform.events.context import get_correlation_id
+from chatballs.events.context import get_correlation_id
if TYPE_CHECKING:
- from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
+ from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
class TenantActorKind(StrEnum):
diff --git a/apps/backend/hub_platform/tenancy/database.py b/apps/backend/chatballs/tenancy/database.py
similarity index 96%
rename from apps/backend/hub_platform/tenancy/database.py
rename to apps/backend/chatballs/tenancy/database.py
index dc0c9f9..e326a4a 100644
--- a/apps/backend/hub_platform/tenancy/database.py
+++ b/apps/backend/chatballs/tenancy/database.py
@@ -6,9 +6,9 @@ from typing import Any, Callable
from django.db import DEFAULT_DB_ALIAS, connections, transaction
-from hub_platform.tenancy.context import TenantContext
+from chatballs.tenancy.context import TenantContext
-ORGANIZATION_SETTING = "custocrm.organization_id"
+ORGANIZATION_SETTING = "chatballs.organization_id"
def _organization_id(context_or_id: TenantContext | int) -> int:
diff --git a/apps/backend/hub_platform/tenancy/ingress.py b/apps/backend/chatballs/tenancy/ingress.py
similarity index 87%
rename from apps/backend/hub_platform/tenancy/ingress.py
rename to apps/backend/chatballs/tenancy/ingress.py
index 8ed381e..150fa73 100644
--- a/apps/backend/hub_platform/tenancy/ingress.py
+++ b/apps/backend/chatballs/tenancy/ingress.py
@@ -6,7 +6,7 @@ from typing import Any
from django.conf import settings
from django.db import connections
-from hub_platform.identity.crypto import decrypt_secret
+from chatballs.identity.crypto import decrypt_secret
@dataclass(frozen=True, slots=True)
@@ -29,7 +29,7 @@ def _rows(query: str, parameters: list[Any]) -> list[tuple]:
def _unique_route(view: str, lookup_key: str) -> IngressRoute | None:
rows = _rows(
- f"SELECT organization_id, resource_id FROM custocrm.{view} "
+ f"SELECT organization_id, resource_id FROM chatballs.{view} "
"WHERE lookup_key = %s ORDER BY resource_id LIMIT 2",
[lookup_key],
)
@@ -42,7 +42,7 @@ def membership_routes_for_user(user_id: int) -> list[IngressRoute]:
return [
IngressRoute(organization_id=int(row[0]), resource_id=int(row[1]))
for row in _rows(
- "SELECT organization_id, resource_id FROM custocrm.membership_directory "
+ "SELECT organization_id, resource_id FROM chatballs.membership_directory "
"WHERE user_id = %s AND blocked_at IS NULL ORDER BY organization_id, resource_id",
[user_id],
)
@@ -52,7 +52,7 @@ def membership_routes_for_user(user_id: int) -> list[IngressRoute]:
def user_requires_totp(user_id: int) -> bool:
return bool(
_rows(
- "SELECT 1 FROM custocrm.membership_directory "
+ "SELECT 1 FROM chatballs.membership_directory "
"WHERE user_id = %s AND blocked_at IS NULL AND totp_required LIMIT 1",
[user_id],
)
@@ -96,7 +96,7 @@ def support_channel_routes(channel_code: str) -> list[SupportIngressRoute]:
)
for row in _rows(
"SELECT organization_id, resource_id, support_token_secret "
- "FROM custocrm.support_channel_directory WHERE lookup_key = %s "
+ "FROM chatballs.support_channel_directory WHERE lookup_key = %s "
"ORDER BY resource_id",
[channel_code],
)
@@ -108,7 +108,7 @@ def support_conversation_route(
snapshot_id: int,
) -> IngressRoute | None:
rows = _rows(
- "SELECT organization_id, resource_id FROM custocrm.support_conversation_directory "
+ "SELECT organization_id, resource_id FROM chatballs.support_conversation_directory "
"WHERE resource_id = %s AND snapshot_id = %s LIMIT 2",
[conversation_id, snapshot_id],
)
diff --git a/apps/backend/hub_platform/tenancy/management/__init__.py b/apps/backend/chatballs/tenancy/management/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/management/__init__.py
rename to apps/backend/chatballs/tenancy/management/__init__.py
diff --git a/apps/backend/hub_platform/tenancy/management/commands/__init__.py b/apps/backend/chatballs/tenancy/management/commands/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/management/commands/__init__.py
rename to apps/backend/chatballs/tenancy/management/commands/__init__.py
diff --git a/apps/backend/hub_platform/tenancy/management/commands/migrate_tenant_media.py b/apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py
similarity index 93%
rename from apps/backend/hub_platform/tenancy/management/commands/migrate_tenant_media.py
rename to apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py
index 1f8ed98..f1f8aa4 100644
--- a/apps/backend/hub_platform/tenancy/management/commands/migrate_tenant_media.py
+++ b/apps/backend/chatballs/tenancy/management/commands/migrate_tenant_media.py
@@ -6,11 +6,11 @@ from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
-from hub_platform.ai.models import KnowledgeAttachment
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.media_migration import (
+from chatballs.ai.models import KnowledgeAttachment
+from chatballs.identity.models import Organization
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.media_migration import (
copy_attachment,
reconcile_attachment_storage_usage,
)
diff --git a/apps/backend/hub_platform/tenancy/media_migration.py b/apps/backend/chatballs/tenancy/media_migration.py
similarity index 94%
rename from apps/backend/hub_platform/tenancy/media_migration.py
rename to apps/backend/chatballs/tenancy/media_migration.py
index 2772cd1..a1e8fec 100644
--- a/apps/backend/hub_platform/tenancy/media_migration.py
+++ b/apps/backend/chatballs/tenancy/media_migration.py
@@ -8,9 +8,9 @@ from django.core.files import File
from django.core.files.storage import default_storage
from django.db import transaction
-from hub_platform.ai.models import KnowledgeAttachment, attachment_upload_path
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.models import OrganizationStorageUsage
+from chatballs.ai.models import KnowledgeAttachment, attachment_upload_path
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.models import OrganizationStorageUsage
@dataclass(frozen=True, slots=True)
diff --git a/apps/backend/hub_platform/tenancy/middleware.py b/apps/backend/chatballs/tenancy/middleware.py
similarity index 90%
rename from apps/backend/hub_platform/tenancy/middleware.py
rename to apps/backend/chatballs/tenancy/middleware.py
index e810bf8..eec6f33 100644
--- a/apps/backend/hub_platform/tenancy/middleware.py
+++ b/apps/backend/chatballs/tenancy/middleware.py
@@ -6,10 +6,10 @@ import uuid
from django.http import Http404, HttpRequest, HttpResponse
-from hub_platform.events.context import get_correlation_id
-from hub_platform.identity.models import Organization, OrganizationMembership
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
+from chatballs.events.context import get_correlation_id
+from chatballs.identity.models import Organization, OrganizationMembership
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
class TenantContextMiddleware:
diff --git a/apps/backend/hub_platform/tenancy/migrations/0001_initial.py b/apps/backend/chatballs/tenancy/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/migrations/0001_initial.py
rename to apps/backend/chatballs/tenancy/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/tenancy/migrations/0002_cross_tenant_constraints.py b/apps/backend/chatballs/tenancy/migrations/0002_cross_tenant_constraints.py
similarity index 93%
rename from apps/backend/hub_platform/tenancy/migrations/0002_cross_tenant_constraints.py
rename to apps/backend/chatballs/tenancy/migrations/0002_cross_tenant_constraints.py
index c774d94..d55e791 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0002_cross_tenant_constraints.py
+++ b/apps/backend/chatballs/tenancy/migrations/0002_cross_tenant_constraints.py
@@ -73,10 +73,10 @@ TENANT_PAIRS = (
def add_constraints(apps, schema_editor):
- schema_editor.execute("CREATE SCHEMA IF NOT EXISTS custocrm")
+ schema_editor.execute("CREATE SCHEMA IF NOT EXISTS chatballs")
schema_editor.execute(
"""
- CREATE OR REPLACE FUNCTION custocrm.enforce_tenant_fk() RETURNS trigger
+ CREATE OR REPLACE FUNCTION chatballs.enforce_tenant_fk() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE parent_org bigint; fk_value text;
BEGIN
@@ -90,7 +90,7 @@ def add_constraints(apps, schema_editor):
RETURN NEW;
END $$;
- CREATE OR REPLACE FUNCTION custocrm.enforce_tenant_user() RETURNS trigger
+ CREATE OR REPLACE FUNCTION chatballs.enforce_tenant_user() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE user_value text;
BEGIN
@@ -106,7 +106,7 @@ def add_constraints(apps, schema_editor):
RETURN NEW;
END $$;
- CREATE OR REPLACE FUNCTION custocrm.enforce_tenant_pair() RETURNS trigger
+ CREATE OR REPLACE FUNCTION chatballs.enforce_tenant_pair() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE left_org bigint; right_org bigint; left_value text; right_value text;
BEGIN
@@ -135,7 +135,7 @@ def add_constraints(apps, schema_editor):
schema_editor.execute(
f"CREATE CONSTRAINT TRIGGER c04_tfk_{index} AFTER INSERT OR UPDATE ON {child} "
"DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "
- f"custocrm.enforce_tenant_fk('{parent}', '{fk_column}')"
+ f"chatballs.enforce_tenant_fk('{parent}', '{fk_column}')"
)
for index, (table, user_column) in enumerate(TENANT_USER_FIELDS):
cursor.execute(
@@ -149,7 +149,7 @@ def add_constraints(apps, schema_editor):
schema_editor.execute(
f"CREATE CONSTRAINT TRIGGER c04_tuser_{index} AFTER INSERT OR UPDATE ON {table} "
"DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "
- f"custocrm.enforce_tenant_user('{user_column}')"
+ f"chatballs.enforce_tenant_user('{user_column}')"
)
for index, (table, left_table, left_column, right_table, right_column) in enumerate(TENANT_PAIRS):
cursor.execute(
@@ -163,7 +163,7 @@ def add_constraints(apps, schema_editor):
schema_editor.execute(
f"CREATE CONSTRAINT TRIGGER c04_tpair_{index} AFTER INSERT OR UPDATE ON {table} "
"DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "
- f"custocrm.enforce_tenant_pair('{left_table}', '{left_column}', "
+ f"chatballs.enforce_tenant_pair('{left_table}', '{left_column}', "
f"'{right_table}', '{right_column}')"
)
@@ -175,9 +175,9 @@ def remove_constraints(apps, schema_editor):
schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_tuser_{index} ON {table}")
for index, (table, *_rest) in enumerate(TENANT_PAIRS):
schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_tpair_{index} ON {table}")
- schema_editor.execute("DROP FUNCTION IF EXISTS custocrm.enforce_tenant_pair()")
- schema_editor.execute("DROP FUNCTION IF EXISTS custocrm.enforce_tenant_user()")
- schema_editor.execute("DROP FUNCTION IF EXISTS custocrm.enforce_tenant_fk()")
+ schema_editor.execute("DROP FUNCTION IF EXISTS chatballs.enforce_tenant_pair()")
+ schema_editor.execute("DROP FUNCTION IF EXISTS chatballs.enforce_tenant_user()")
+ schema_editor.execute("DROP FUNCTION IF EXISTS chatballs.enforce_tenant_fk()")
class Migration(migrations.Migration):
diff --git a/apps/backend/hub_platform/tenancy/migrations/0003_rls_policies.py b/apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py
similarity index 52%
rename from apps/backend/hub_platform/tenancy/migrations/0003_rls_policies.py
rename to apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py
index 30d7f89..a385972 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0003_rls_policies.py
+++ b/apps/backend/chatballs/tenancy/migrations/0003_rls_policies.py
@@ -49,7 +49,7 @@ INDIRECT_TABLE_POLICIES = {
SELECT 1 FROM ai_aiagent agent
JOIN ai_knowledge knowledge ON knowledge.id = knowledge_id
WHERE agent.id = aiagent_id
- AND agent.organization_id = custocrm.current_organization_id()
+ AND agent.organization_id = chatballs.current_organization_id()
AND knowledge.organization_id = agent.organization_id
)
""",
@@ -58,7 +58,7 @@ INDIRECT_TABLE_POLICIES = {
SELECT 1 FROM support_productsupportcontract contract
JOIN channels_channel channel ON channel.id = channel_id
WHERE contract.id = productsupportcontract_id
- AND contract.organization_id = custocrm.current_organization_id()
+ AND contract.organization_id = chatballs.current_organization_id()
AND channel.organization_id = contract.organization_id
)
""",
@@ -76,47 +76,47 @@ def _ensure_roles(schema_editor):
"""
DO $$
BEGIN
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_app') THEN
- CREATE ROLE custocrm_runtime_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chatballs_runtime_app') THEN
+ CREATE ROLE chatballs_runtime_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
END IF;
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_platform') THEN
- CREATE ROLE custocrm_runtime_platform NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chatballs_runtime_platform') THEN
+ CREATE ROLE chatballs_runtime_platform NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
END IF;
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_schema') THEN
- CREATE ROLE custocrm_schema NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chatballs_schema') THEN
+ CREATE ROLE chatballs_schema NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
END IF;
- IF NOT pg_has_role(current_user, 'custocrm_schema', 'MEMBER') THEN
- EXECUTE format('GRANT custocrm_schema TO %%I', current_user);
+ IF NOT pg_has_role(current_user, 'chatballs_schema', 'MEMBER') THEN
+ EXECUTE format('GRANT chatballs_schema TO %%I', current_user);
END IF;
END $$;
- CREATE SCHEMA IF NOT EXISTS custocrm AUTHORIZATION custocrm_schema;
- ALTER SCHEMA custocrm OWNER TO custocrm_schema;
- REVOKE ALL ON SCHEMA custocrm FROM PUBLIC;
- GRANT USAGE ON SCHEMA custocrm TO custocrm_runtime_app, custocrm_runtime_platform;
+ CREATE SCHEMA IF NOT EXISTS chatballs AUTHORIZATION chatballs_schema;
+ ALTER SCHEMA chatballs OWNER TO chatballs_schema;
+ REVOKE ALL ON SCHEMA chatballs FROM PUBLIC;
+ GRANT USAGE ON SCHEMA chatballs TO chatballs_runtime_app, chatballs_runtime_platform;
- CREATE OR REPLACE FUNCTION custocrm.current_organization_id() RETURNS bigint
+ CREATE OR REPLACE FUNCTION chatballs.current_organization_id() RETURNS bigint
LANGUAGE sql STABLE PARALLEL SAFE AS $$
SELECT CASE
- WHEN current_setting('custocrm.organization_id', true) ~ '^[1-9][0-9]*$'
- THEN current_setting('custocrm.organization_id', true)::bigint
+ WHEN current_setting('chatballs.organization_id', true) ~ '^[1-9][0-9]*$'
+ THEN current_setting('chatballs.organization_id', true)::bigint
ELSE NULL
END
$$;
- ALTER FUNCTION custocrm.current_organization_id() OWNER TO custocrm_schema;
- ALTER FUNCTION custocrm.enforce_tenant_fk() OWNER TO custocrm_schema;
- ALTER FUNCTION custocrm.enforce_tenant_user() OWNER TO custocrm_schema;
- ALTER FUNCTION custocrm.enforce_tenant_pair() OWNER TO custocrm_schema;
- REVOKE ALL ON FUNCTION custocrm.current_organization_id() FROM PUBLIC;
- REVOKE ALL ON FUNCTION custocrm.enforce_tenant_fk() FROM PUBLIC;
- REVOKE ALL ON FUNCTION custocrm.enforce_tenant_user() FROM PUBLIC;
- REVOKE ALL ON FUNCTION custocrm.enforce_tenant_pair() FROM PUBLIC;
- GRANT EXECUTE ON FUNCTION custocrm.current_organization_id()
- TO custocrm_runtime_app, custocrm_runtime_platform;
- GRANT EXECUTE ON FUNCTION custocrm.enforce_tenant_fk(),
- custocrm.enforce_tenant_user(), custocrm.enforce_tenant_pair()
- TO custocrm_runtime_app, custocrm_schema;
- GRANT USAGE ON SCHEMA public TO custocrm_runtime_app, custocrm_runtime_platform;
+ ALTER FUNCTION chatballs.current_organization_id() OWNER TO chatballs_schema;
+ ALTER FUNCTION chatballs.enforce_tenant_fk() OWNER TO chatballs_schema;
+ ALTER FUNCTION chatballs.enforce_tenant_user() OWNER TO chatballs_schema;
+ ALTER FUNCTION chatballs.enforce_tenant_pair() OWNER TO chatballs_schema;
+ REVOKE ALL ON FUNCTION chatballs.current_organization_id() FROM PUBLIC;
+ REVOKE ALL ON FUNCTION chatballs.enforce_tenant_fk() FROM PUBLIC;
+ REVOKE ALL ON FUNCTION chatballs.enforce_tenant_user() FROM PUBLIC;
+ REVOKE ALL ON FUNCTION chatballs.enforce_tenant_pair() FROM PUBLIC;
+ GRANT EXECUTE ON FUNCTION chatballs.current_organization_id()
+ TO chatballs_runtime_app, chatballs_runtime_platform;
+ GRANT EXECUTE ON FUNCTION chatballs.enforce_tenant_fk(),
+ chatballs.enforce_tenant_user(), chatballs.enforce_tenant_pair()
+ TO chatballs_runtime_app, chatballs_schema;
+ GRANT USAGE ON SCHEMA public TO chatballs_runtime_app, chatballs_runtime_platform;
"""
)
@@ -126,69 +126,69 @@ def enable_rls(apps, schema_editor):
for table in TENANT_TABLES:
schema_editor.execute(
f"""
- ALTER TABLE {table} OWNER TO custocrm_schema;
+ ALTER TABLE {table} OWNER TO chatballs_schema;
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {table} FROM PUBLIC;
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app;
- GRANT ALL ON TABLE {table} TO custocrm_schema;
- DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
- CREATE POLICY custocrm_tenant_isolation ON {table}
- FOR ALL TO custocrm_runtime_app
- 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);
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO chatballs_runtime_app;
+ GRANT ALL ON TABLE {table} TO chatballs_schema;
+ DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+ CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+ DROP POLICY IF EXISTS chatballs_schema_access ON {table};
+ CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
)
for table, expression in INDIRECT_TABLE_POLICIES.items():
schema_editor.execute(
f"""
- ALTER TABLE {table} OWNER TO custocrm_schema;
+ ALTER TABLE {table} OWNER TO chatballs_schema;
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {table} FROM PUBLIC;
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app;
- GRANT ALL ON TABLE {table} TO custocrm_schema;
- DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
- CREATE POLICY custocrm_tenant_isolation ON {table}
- FOR ALL TO custocrm_runtime_app
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO chatballs_runtime_app;
+ GRANT ALL ON TABLE {table} TO chatballs_schema;
+ DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+ CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app
USING ({expression}) WITH CHECK ({expression});
- 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);
+ DROP POLICY IF EXISTS chatballs_schema_access ON {table};
+ CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
)
for table in MIXED_PLATFORM_TABLES:
schema_editor.execute(
f"""
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_platform;
- DROP POLICY IF EXISTS custocrm_platform_boundary ON {table};
- CREATE POLICY custocrm_platform_boundary ON {table}
- FOR ALL TO custocrm_runtime_platform USING (true) WITH CHECK (true);
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO chatballs_runtime_platform;
+ DROP POLICY IF EXISTS chatballs_platform_boundary ON {table};
+ CREATE POLICY chatballs_platform_boundary ON {table}
+ FOR ALL TO chatballs_runtime_platform USING (true) WITH CHECK (true);
"""
)
schema_editor.execute(
"""
- DROP POLICY IF EXISTS custocrm_app_platform_audit_insert ON identity_auditevent;
- CREATE POLICY custocrm_app_platform_audit_insert ON identity_auditevent
- FOR INSERT TO custocrm_runtime_app WITH CHECK (organization_id IS NULL);
- DROP POLICY IF EXISTS custocrm_app_platform_outbox_insert ON events_outboxevent;
- CREATE POLICY custocrm_app_platform_outbox_insert ON events_outboxevent
- FOR INSERT TO custocrm_runtime_app
+ DROP POLICY IF EXISTS chatballs_app_platform_audit_insert ON identity_auditevent;
+ CREATE POLICY chatballs_app_platform_audit_insert ON identity_auditevent
+ FOR INSERT TO chatballs_runtime_app WITH CHECK (organization_id IS NULL);
+ DROP POLICY IF EXISTS chatballs_app_platform_outbox_insert ON events_outboxevent;
+ CREATE POLICY chatballs_app_platform_outbox_insert ON events_outboxevent
+ FOR INSERT TO chatballs_runtime_app
WITH CHECK (ownership = 'PLATFORM' AND organization_id IS NULL);
- GRANT SELECT ON identity_organization TO custocrm_runtime_app, custocrm_runtime_platform;
+ GRANT SELECT ON identity_organization TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT SELECT, INSERT, UPDATE, DELETE ON identity_humanuser
- TO custocrm_runtime_app, custocrm_runtime_platform;
+ TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT SELECT, INSERT, UPDATE, DELETE ON django_session
- TO custocrm_runtime_app, custocrm_runtime_platform;
+ TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT SELECT ON django_content_type, auth_permission, auth_group,
auth_group_permissions, identity_humanuser_groups, identity_humanuser_user_permissions
- TO custocrm_runtime_app, custocrm_runtime_platform;
+ TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public
- TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema;
+ TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema;
"""
)
diff --git a/apps/backend/hub_platform/tenancy/migrations/0004_ingress_directory.py b/apps/backend/chatballs/tenancy/migrations/0004_ingress_directory.py
similarity index 87%
rename from apps/backend/hub_platform/tenancy/migrations/0004_ingress_directory.py
rename to apps/backend/chatballs/tenancy/migrations/0004_ingress_directory.py
index 21465bc..3075495 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0004_ingress_directory.py
+++ b/apps/backend/chatballs/tenancy/migrations/0004_ingress_directory.py
@@ -66,18 +66,18 @@ VIEWS = {
def create_views(apps, schema_editor):
for name, query in VIEWS.items():
schema_editor.execute(
- f"CREATE OR REPLACE VIEW custocrm.{name} WITH (security_barrier = true) AS {query}"
+ f"CREATE OR REPLACE VIEW chatballs.{name} WITH (security_barrier = true) AS {query}"
)
- schema_editor.execute(f"ALTER VIEW custocrm.{name} OWNER TO custocrm_schema")
- schema_editor.execute(f"REVOKE ALL ON custocrm.{name} FROM PUBLIC")
+ schema_editor.execute(f"ALTER VIEW chatballs.{name} OWNER TO chatballs_schema")
+ schema_editor.execute(f"REVOKE ALL ON chatballs.{name} FROM PUBLIC")
schema_editor.execute(
- f"GRANT SELECT ON custocrm.{name} TO custocrm_runtime_platform"
+ f"GRANT SELECT ON chatballs.{name} TO chatballs_runtime_platform"
)
def drop_views(apps, schema_editor):
for name in reversed(VIEWS):
- schema_editor.execute(f"DROP VIEW IF EXISTS custocrm.{name}")
+ schema_editor.execute(f"DROP VIEW IF EXISTS chatballs.{name}")
class Migration(migrations.Migration):
diff --git a/apps/backend/hub_platform/tenancy/migrations/0005_platform_provisioning_grants.py b/apps/backend/chatballs/tenancy/migrations/0005_platform_provisioning_grants.py
similarity index 69%
rename from apps/backend/hub_platform/tenancy/migrations/0005_platform_provisioning_grants.py
rename to apps/backend/chatballs/tenancy/migrations/0005_platform_provisioning_grants.py
index 99f69f8..08fecf7 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0005_platform_provisioning_grants.py
+++ b/apps/backend/chatballs/tenancy/migrations/0005_platform_provisioning_grants.py
@@ -1,10 +1,10 @@
# C06 tenant provisioning DB boundary (SPEC-HUB-0021 §10, ADR-HUB-0029 §6).
#
-# Provisioning runs on the platform connection (custocrm_runtime_platform) and must
+# Provisioning runs on the platform connection (chatballs_runtime_platform) and must
# both INSERT a new Organization and create tenant-owned rows (departments, the
# OWNER membership) inside set_local_tenant(new_org_id). Today identity_organization
# has only GRANT SELECT to runtime roles, and identity_department /
-# identity_employeeprofile grant DML to custocrm_runtime_app only. This migration
+# identity_employeeprofile grant DML to chatballs_runtime_app only. This migration
# extends the platform role with the minimal grants and tenant-isolation policies
# it needs, mirroring the subscriptions tenant policy (both roles, matching tenant
# context only). It does not weaken isolation: the platform role still cannot read
@@ -12,7 +12,7 @@
from django.db import migrations
# identity_department and identity_employeeprofile are already ENABLE/FORCE RLS in
-# 0003 for custocrm_runtime_app. Here we grant the platform role DML and add a
+# 0003 for chatballs_runtime_app. Here we grant the platform role DML and add a
# tenant-isolation policy identical in shape to the subscriptions policy, so the
# platform role sees only matching tenant context. organization_id is the direct
# tenant key on both tables (0002_cross_tenant_constraints / 0012 membership).
@@ -21,7 +21,7 @@ PROVISIONING_TENANT_TABLES = (
"identity_employeeprofile",
)
-PLATFORM_POLICY = "custocrm_platform_tenant_provisioning"
+PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
def apply_grants(apps, schema_editor):
@@ -29,22 +29,22 @@ def apply_grants(apps, schema_editor):
# it has no organization_id column). DELETE stays with the schema/migration
# role per SPEC-HUB-0021 §14 (hard delete as compensation is forbidden).
schema_editor.execute(
- "GRANT INSERT, UPDATE ON identity_organization TO custocrm_runtime_platform"
+ "GRANT INSERT, UPDATE ON identity_organization TO chatballs_runtime_platform"
)
# 2. For each provisioning tenant table: grant DML to the platform role and add
- # a tenant-isolation policy mirroring custocrm_subscription_tenant.
+ # a tenant-isolation policy mirroring chatballs_subscription_tenant.
for table in PROVISIONING_TENANT_TABLES:
schema_editor.execute(
- f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_platform"
+ f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO chatballs_runtime_platform"
)
schema_editor.execute(f"DROP POLICY IF EXISTS {PLATFORM_POLICY} ON {table}")
schema_editor.execute(
f"""
CREATE POLICY {PLATFORM_POLICY} ON {table}
- FOR ALL TO custocrm_runtime_platform
- USING (organization_id = custocrm.current_organization_id())
- WITH CHECK (organization_id = custocrm.current_organization_id())
+ FOR ALL TO chatballs_runtime_platform
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id())
"""
)
@@ -53,28 +53,28 @@ def apply_grants(apps, schema_editor):
# plain security-barrier view is sufficient and consistent with 0004.
schema_editor.execute(
"""
- CREATE OR REPLACE VIEW custocrm.organization_directory
+ CREATE OR REPLACE VIEW chatballs.organization_directory
WITH (security_barrier = true) AS
SELECT id AS organization_id, public_id, status, slug
FROM identity_organization
"""
)
- schema_editor.execute("ALTER VIEW custocrm.organization_directory OWNER TO custocrm_schema")
- schema_editor.execute("REVOKE ALL ON custocrm.organization_directory FROM PUBLIC")
+ schema_editor.execute("ALTER VIEW chatballs.organization_directory OWNER TO chatballs_schema")
+ schema_editor.execute("REVOKE ALL ON chatballs.organization_directory FROM PUBLIC")
schema_editor.execute(
- "GRANT SELECT ON custocrm.organization_directory TO custocrm_runtime_platform"
+ "GRANT SELECT ON chatballs.organization_directory TO chatballs_runtime_platform"
)
def revert_grants(apps, schema_editor):
- schema_editor.execute("DROP VIEW IF EXISTS custocrm.organization_directory")
+ schema_editor.execute("DROP VIEW IF EXISTS chatballs.organization_directory")
for table in PROVISIONING_TENANT_TABLES:
schema_editor.execute(f"DROP POLICY IF EXISTS {PLATFORM_POLICY} ON {table}")
schema_editor.execute(
- f"REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLE {table} FROM custocrm_runtime_platform"
+ f"REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLE {table} FROM chatballs_runtime_platform"
)
schema_editor.execute(
- "REVOKE INSERT, UPDATE ON identity_organization FROM custocrm_runtime_platform"
+ "REVOKE INSERT, UPDATE ON identity_organization FROM chatballs_runtime_platform"
)
diff --git a/apps/backend/hub_platform/tenancy/migrations/0006_storage_reserved_bytes.py b/apps/backend/chatballs/tenancy/migrations/0006_storage_reserved_bytes.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/migrations/0006_storage_reserved_bytes.py
rename to apps/backend/chatballs/tenancy/migrations/0006_storage_reserved_bytes.py
diff --git a/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py b/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py
new file mode 100644
index 0000000..4b47347
--- /dev/null
+++ b/apps/backend/chatballs/tenancy/migrations/0007_platform_row_select_policies.py
@@ -0,0 +1,27 @@
+from django.db import migrations
+
+# Django ORM inserts use INSERT ... RETURNING id; PostgreSQL applies SELECT
+# policies to rows returned by RETURNING, so the app role needs SELECT
+# visibility of the platform-scope rows it is allowed to insert
+# (chatballs_app_platform_audit_insert / chatballs_app_platform_outbox_insert).
+# Without these policies a login-failed audit write fails with
+# "new row violates row-level security policy".
+CREATE_POLICIES = """
+DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent;
+CREATE POLICY chatballs_app_platform_audit_select ON identity_auditevent
+ FOR SELECT TO chatballs_runtime_app USING (organization_id IS NULL);
+DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent;
+CREATE POLICY chatballs_app_platform_outbox_select ON events_outboxevent
+ FOR SELECT TO chatballs_runtime_app
+ USING (ownership = 'PLATFORM' AND organization_id IS NULL);
+"""
+
+DROP_POLICIES = """
+DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent;
+DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent;
+"""
+
+
+class Migration(migrations.Migration):
+ dependencies = [("tenancy", "0006_storage_reserved_bytes")]
+ operations = [migrations.RunSQL(CREATE_POLICIES, DROP_POLICIES)]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0008_ai_knowledge_scope_guards.py b/apps/backend/chatballs/tenancy/migrations/0008_ai_knowledge_scope_guards.py
similarity index 68%
rename from apps/backend/hub_platform/tenancy/migrations/0008_ai_knowledge_scope_guards.py
rename to apps/backend/chatballs/tenancy/migrations/0008_ai_knowledge_scope_guards.py
index 05c42ed..d556a92 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0008_ai_knowledge_scope_guards.py
+++ b/apps/backend/chatballs/tenancy/migrations/0008_ai_knowledge_scope_guards.py
@@ -2,7 +2,7 @@ from django.db import migrations
TENANT_TABLES = ("ai_knowledgecategory", "ai_knowledgedepartment")
-PLATFORM_POLICY = "custocrm_platform_tenant_provisioning"
+PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
TENANT_RELATIONS = (
(
"b01_knowledge_category_parent",
@@ -30,34 +30,34 @@ def add_scope_guards(apps, schema_editor):
for table in TENANT_TABLES:
schema_editor.execute(
f"""
- ALTER TABLE {table} OWNER TO custocrm_schema;
+ ALTER TABLE {table} OWNER TO chatballs_schema;
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {table} FROM PUBLIC;
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app;
- GRANT ALL ON TABLE {table} TO custocrm_schema;
- CREATE POLICY custocrm_tenant_isolation ON {table}
- FOR ALL TO custocrm_runtime_app
- USING (organization_id = custocrm.current_organization_id())
- WITH CHECK (organization_id = custocrm.current_organization_id());
- CREATE POLICY custocrm_schema_access ON {table}
- FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO chatballs_runtime_app;
+ GRANT ALL ON TABLE {table} TO chatballs_schema;
+ CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+ CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
)
schema_editor.execute(
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public "
- "TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema"
+ "TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema"
)
schema_editor.execute(
"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ai_knowledgecategory "
- "TO custocrm_runtime_platform"
+ "TO chatballs_runtime_platform"
)
schema_editor.execute(
f"""
CREATE POLICY {PLATFORM_POLICY} ON ai_knowledgecategory
- FOR ALL TO custocrm_runtime_platform
- USING (organization_id = custocrm.current_organization_id())
- WITH CHECK (organization_id = custocrm.current_organization_id())
+ FOR ALL TO chatballs_runtime_platform
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id())
"""
)
for trigger, child, parent, column in TENANT_RELATIONS:
@@ -66,7 +66,7 @@ def add_scope_guards(apps, schema_editor):
CREATE CONSTRAINT TRIGGER {trigger}
AFTER INSERT OR UPDATE ON {child}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
- custocrm.enforce_tenant_fk('{parent}', '{column}');
+ chatballs.enforce_tenant_fk('{parent}', '{column}');
"""
)
@@ -77,15 +77,15 @@ def remove_scope_guards(apps, schema_editor):
)
schema_editor.execute(
"REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLE ai_knowledgecategory "
- "FROM custocrm_runtime_platform"
+ "FROM chatballs_runtime_platform"
)
for trigger, child, _parent, _column in TENANT_RELATIONS:
schema_editor.execute(f"DROP TRIGGER IF EXISTS {trigger} ON {child}")
for table in TENANT_TABLES:
schema_editor.execute(
f"""
- DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
- DROP POLICY IF EXISTS custocrm_schema_access ON {table};
+ DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+ DROP POLICY IF EXISTS chatballs_schema_access ON {table};
ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY;
ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;
"""
diff --git a/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py b/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py
new file mode 100644
index 0000000..4010764
--- /dev/null
+++ b/apps/backend/chatballs/tenancy/migrations/0009_tenant_guards_security_definer.py
@@ -0,0 +1,43 @@
+"""Guard-функции C04 должны видеть строки поверх RLS (SECURITY DEFINER).
+
+Функции проверяют целостность связей между тенантами и обязаны читать
+родительскую строку независимо от политик вызывающей роли. Как SECURITY
+INVOKER их внутренний SELECT подчинялся RLS: из кросс-тенантного пути без
+выставленного `chatballs.organization_id` (outbox-воркер claim'ит событие
+любой организации) родитель не виден, parent_org = NULL, и проверка
+`IS DISTINCT FROM` ложно срабатывала — легитимный UPDATE падал с
+`cross-tenant relation`, что уводило воркер в краш-петлю.
+
+SECURITY DEFINER исполняет их от chatballs_schema (policy USING (true)),
+поэтому сравнение идёт по фактическим данным. Проверка не ослабляется:
+настоящее нарушение по-прежнему приводит к RAISE. search_path закреплён,
+как того требует безопасность SECURITY DEFINER-функций.
+"""
+
+from django.db import migrations
+
+
+GUARD_FUNCTIONS = (
+ "chatballs.enforce_tenant_fk()",
+ "chatballs.enforce_tenant_user()",
+ "chatballs.enforce_tenant_pair()",
+)
+
+
+def set_security_definer(apps, schema_editor):
+ for function in GUARD_FUNCTIONS:
+ schema_editor.execute(
+ f"ALTER FUNCTION {function} SECURITY DEFINER SET search_path = public, pg_temp"
+ )
+
+
+def set_security_invoker(apps, schema_editor):
+ for function in GUARD_FUNCTIONS:
+ schema_editor.execute(
+ f"ALTER FUNCTION {function} SECURITY INVOKER RESET search_path"
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [("tenancy", "0008_ai_knowledge_scope_guards")]
+ operations = [migrations.RunPython(set_security_definer, set_security_invoker)]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py b/apps/backend/chatballs/tenancy/migrations/0010_support_portal_guards.py
similarity index 70%
rename from apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py
rename to apps/backend/chatballs/tenancy/migrations/0010_support_portal_guards.py
index ef17179..fba1b5c 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0010_support_portal_guards.py
+++ b/apps/backend/chatballs/tenancy/migrations/0010_support_portal_guards.py
@@ -38,14 +38,14 @@ def install(apps, schema_editor):
FROM pg_auth_members membership
JOIN pg_roles parent ON parent.oid = membership.roleid
JOIN pg_roles child ON child.oid = membership.member
- WHERE parent.rolname = 'custocrm_schema'
+ WHERE parent.rolname = 'chatballs_schema'
AND child.rolname IN (
- 'custocrm_runtime_app',
- 'custocrm_runtime_platform'
+ 'chatballs_runtime_app',
+ 'chatballs_runtime_platform'
)
) THEN
- REVOKE custocrm_schema
- FROM custocrm_runtime_app, custocrm_runtime_platform;
+ REVOKE chatballs_schema
+ FROM chatballs_runtime_app, chatballs_runtime_platform;
END IF;
END
$$;
@@ -54,18 +54,18 @@ def install(apps, schema_editor):
for table in TABLES:
schema_editor.execute(
f"""
- ALTER TABLE {table} OWNER TO custocrm_schema;
+ ALTER TABLE {table} OWNER TO chatballs_schema;
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {table} FROM PUBLIC;
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO custocrm_runtime_app;
- GRANT ALL ON TABLE {table} TO custocrm_schema;
- CREATE POLICY custocrm_tenant_isolation ON {table}
- FOR ALL TO custocrm_runtime_app
- USING (organization_id = custocrm.current_organization_id())
- WITH CHECK (organization_id = custocrm.current_organization_id());
- CREATE POLICY custocrm_schema_access ON {table}
- FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO chatballs_runtime_app;
+ GRANT ALL ON TABLE {table} TO chatballs_schema;
+ CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+ CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
)
for trigger, child, parent, column in RELATIONS:
@@ -74,12 +74,12 @@ def install(apps, schema_editor):
CREATE CONSTRAINT TRIGGER {trigger}
AFTER INSERT OR UPDATE ON {child}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
- custocrm.enforce_tenant_fk('{parent}', '{column}');
+ chatballs.enforce_tenant_fk('{parent}', '{column}');
"""
)
schema_editor.execute(
"""
- CREATE OR REPLACE VIEW custocrm.support_portal_directory
+ CREATE OR REPLACE VIEW chatballs.support_portal_directory
WITH (security_barrier = true) AS
SELECT portal.id AS resource_id,
portal.organization_id,
@@ -90,24 +90,24 @@ def install(apps, schema_editor):
AND department.organization_id = portal.organization_id
WHERE portal.status = 'PUBLISHED'
AND department.code = 'support';
- ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema;
- REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC;
- GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform;
+ ALTER VIEW chatballs.support_portal_directory OWNER TO chatballs_schema;
+ REVOKE ALL ON chatballs.support_portal_directory FROM PUBLIC;
+ GRANT SELECT ON chatballs.support_portal_directory TO chatballs_runtime_platform;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public
- TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema;
+ TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema;
"""
)
def uninstall(apps, schema_editor):
- schema_editor.execute("DROP VIEW IF EXISTS custocrm.support_portal_directory")
+ schema_editor.execute("DROP VIEW IF EXISTS chatballs.support_portal_directory")
for trigger, child, _parent, _column in RELATIONS:
schema_editor.execute(f"DROP TRIGGER IF EXISTS {trigger} ON {child}")
for table in TABLES:
schema_editor.execute(
f"""
- DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
- DROP POLICY IF EXISTS custocrm_schema_access ON {table};
+ DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+ DROP POLICY IF EXISTS chatballs_schema_access ON {table};
ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY;
ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;
"""
diff --git a/apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py b/apps/backend/chatballs/tenancy/migrations/0011_support_portal_host_directory.py
similarity index 72%
rename from apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py
rename to apps/backend/chatballs/tenancy/migrations/0011_support_portal_host_directory.py
index bcb3deb..59ec8e0 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0011_support_portal_host_directory.py
+++ b/apps/backend/chatballs/tenancy/migrations/0011_support_portal_host_directory.py
@@ -2,8 +2,8 @@ from django.db import migrations
FORWARD_SQL = """
-DROP VIEW custocrm.support_portal_directory;
-CREATE VIEW custocrm.support_portal_directory
+DROP VIEW chatballs.support_portal_directory;
+CREATE VIEW chatballs.support_portal_directory
WITH (security_barrier = true) AS
SELECT portal.id AS resource_id,
portal.organization_id,
@@ -26,14 +26,14 @@ WITH (security_barrier = true) AS
AND department.code = 'support'
AND portal.custom_domain <> ''
AND portal.custom_domain_verified_at IS NOT NULL;
-ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema;
-REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC;
-GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform;
+ALTER VIEW chatballs.support_portal_directory OWNER TO chatballs_schema;
+REVOKE ALL ON chatballs.support_portal_directory FROM PUBLIC;
+GRANT SELECT ON chatballs.support_portal_directory TO chatballs_runtime_platform;
"""
REVERSE_SQL = """
-DROP VIEW custocrm.support_portal_directory;
-CREATE VIEW custocrm.support_portal_directory
+DROP VIEW chatballs.support_portal_directory;
+CREATE VIEW chatballs.support_portal_directory
WITH (security_barrier = true) AS
SELECT portal.id AS resource_id,
portal.organization_id,
@@ -44,9 +44,9 @@ WITH (security_barrier = true) AS
AND department.organization_id = portal.organization_id
WHERE portal.status = 'PUBLISHED'
AND department.code = 'support';
-ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema;
-REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC;
-GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform;
+ALTER VIEW chatballs.support_portal_directory OWNER TO chatballs_schema;
+REVOKE ALL ON chatballs.support_portal_directory FROM PUBLIC;
+GRANT SELECT ON chatballs.support_portal_directory TO chatballs_runtime_platform;
"""
diff --git a/apps/backend/chatballs/tenancy/migrations/0012_organization_settings_rls.py b/apps/backend/chatballs/tenancy/migrations/0012_organization_settings_rls.py
new file mode 100644
index 0000000..5b15fc0
--- /dev/null
+++ b/apps/backend/chatballs/tenancy/migrations/0012_organization_settings_rls.py
@@ -0,0 +1,56 @@
+from django.db import migrations
+
+
+FORWARD_SQL = """
+ALTER TABLE identity_organization OWNER TO chatballs_schema;
+ALTER TABLE identity_organization ENABLE ROW LEVEL SECURITY;
+ALTER TABLE identity_organization FORCE ROW LEVEL SECURITY;
+REVOKE ALL ON identity_organization FROM PUBLIC;
+
+GRANT SELECT, UPDATE ON identity_organization TO chatballs_runtime_app;
+GRANT SELECT, INSERT, UPDATE ON identity_organization TO chatballs_runtime_platform;
+GRANT ALL ON identity_organization TO chatballs_schema;
+
+DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization;
+CREATE POLICY chatballs_organization_app_select ON identity_organization
+ FOR SELECT TO chatballs_runtime_app
+ USING (true);
+
+DROP POLICY IF EXISTS chatballs_organization_app_update ON identity_organization;
+CREATE POLICY chatballs_organization_app_update ON identity_organization
+ FOR UPDATE TO chatballs_runtime_app
+ USING (id = chatballs.current_organization_id())
+ WITH CHECK (id = chatballs.current_organization_id());
+
+DROP POLICY IF EXISTS chatballs_organization_platform ON identity_organization;
+CREATE POLICY chatballs_organization_platform ON identity_organization
+ FOR ALL TO chatballs_runtime_platform
+ USING (true)
+ WITH CHECK (true);
+
+DROP POLICY IF EXISTS chatballs_organization_schema ON identity_organization;
+CREATE POLICY chatballs_organization_schema ON identity_organization
+ FOR ALL TO chatballs_schema
+ USING (true)
+ WITH CHECK (true);
+"""
+
+
+REVERSE_SQL = """
+DROP POLICY IF EXISTS chatballs_organization_app_select ON identity_organization;
+DROP POLICY IF EXISTS chatballs_organization_app_update ON identity_organization;
+DROP POLICY IF EXISTS chatballs_organization_platform ON identity_organization;
+DROP POLICY IF EXISTS chatballs_organization_schema ON identity_organization;
+ALTER TABLE identity_organization NO FORCE ROW LEVEL SECURITY;
+ALTER TABLE identity_organization DISABLE ROW LEVEL SECURITY;
+REVOKE UPDATE ON identity_organization FROM chatballs_runtime_app;
+"""
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("identity", "0017_organization_branding"),
+ ("tenancy", "0011_support_portal_host_directory"),
+ ]
+
+ operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
diff --git a/apps/backend/chatballs/tenancy/migrations/0013_storage_reservation_rls.py b/apps/backend/chatballs/tenancy/migrations/0013_storage_reservation_rls.py
new file mode 100644
index 0000000..00f9635
--- /dev/null
+++ b/apps/backend/chatballs/tenancy/migrations/0013_storage_reservation_rls.py
@@ -0,0 +1,46 @@
+from django.db import migrations
+
+
+FORWARD_SQL = """
+ALTER TABLE tenancy_storagereservation OWNER TO chatballs_schema;
+ALTER TABLE tenancy_storagereservation ENABLE ROW LEVEL SECURITY;
+ALTER TABLE tenancy_storagereservation FORCE ROW LEVEL SECURITY;
+REVOKE ALL ON tenancy_storagereservation FROM PUBLIC;
+
+GRANT SELECT, INSERT, UPDATE, DELETE
+ ON tenancy_storagereservation TO chatballs_runtime_app;
+GRANT ALL ON tenancy_storagereservation TO chatballs_schema;
+GRANT USAGE, SELECT ON SEQUENCE tenancy_storagereservation_id_seq
+ TO chatballs_runtime_app, chatballs_schema;
+
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON tenancy_storagereservation;
+CREATE POLICY chatballs_tenant_isolation ON tenancy_storagereservation
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+
+DROP POLICY IF EXISTS chatballs_schema_access ON tenancy_storagereservation;
+CREATE POLICY chatballs_schema_access ON tenancy_storagereservation
+ FOR ALL TO chatballs_schema
+ USING (true)
+ WITH CHECK (true);
+"""
+
+
+REVERSE_SQL = """
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON tenancy_storagereservation;
+DROP POLICY IF EXISTS chatballs_schema_access ON tenancy_storagereservation;
+ALTER TABLE tenancy_storagereservation NO FORCE ROW LEVEL SECURITY;
+ALTER TABLE tenancy_storagereservation DISABLE ROW LEVEL SECURITY;
+REVOKE ALL ON tenancy_storagereservation FROM chatballs_runtime_app;
+REVOKE USAGE, SELECT ON SEQUENCE tenancy_storagereservation_id_seq
+ FROM chatballs_runtime_app;
+"""
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tenancy", "0012_organization_settings_rls"),
+ ]
+
+ operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0014_agent_portal_article_guards.py b/apps/backend/chatballs/tenancy/migrations/0014_agent_portal_article_guards.py
similarity index 76%
rename from apps/backend/hub_platform/tenancy/migrations/0014_agent_portal_article_guards.py
rename to apps/backend/chatballs/tenancy/migrations/0014_agent_portal_article_guards.py
index 4911440..9f25cc6 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0014_agent_portal_article_guards.py
+++ b/apps/backend/chatballs/tenancy/migrations/0014_agent_portal_article_guards.py
@@ -15,7 +15,7 @@ LINK_POLICY = """
SELECT 1 FROM ai_aiagent agent
JOIN support_portals_portalarticle article ON article.id = portalarticle_id
WHERE agent.id = aiagent_id
- AND agent.organization_id = custocrm.current_organization_id()
+ AND agent.organization_id = chatballs.current_organization_id()
AND article.organization_id = agent.organization_id
)
"""
@@ -24,19 +24,19 @@ LINK_POLICY = """
def install(apps, schema_editor):
schema_editor.execute(
f"""
- ALTER TABLE {LINK_TABLE} OWNER TO custocrm_schema;
+ ALTER TABLE {LINK_TABLE} OWNER TO chatballs_schema;
ALTER TABLE {LINK_TABLE} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {LINK_TABLE} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {LINK_TABLE} FROM PUBLIC;
- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {LINK_TABLE} TO custocrm_runtime_app;
- GRANT ALL ON TABLE {LINK_TABLE} TO custocrm_schema;
- DROP POLICY IF EXISTS custocrm_tenant_isolation ON {LINK_TABLE};
- CREATE POLICY custocrm_tenant_isolation ON {LINK_TABLE}
- FOR ALL TO custocrm_runtime_app
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {LINK_TABLE} TO chatballs_runtime_app;
+ GRANT ALL ON TABLE {LINK_TABLE} TO chatballs_schema;
+ DROP POLICY IF EXISTS chatballs_tenant_isolation ON {LINK_TABLE};
+ CREATE POLICY chatballs_tenant_isolation ON {LINK_TABLE}
+ FOR ALL TO chatballs_runtime_app
USING ({LINK_POLICY}) WITH CHECK ({LINK_POLICY});
- DROP POLICY IF EXISTS custocrm_schema_access ON {LINK_TABLE};
- CREATE POLICY custocrm_schema_access ON {LINK_TABLE}
- FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
+ DROP POLICY IF EXISTS chatballs_schema_access ON {LINK_TABLE};
+ CREATE POLICY chatballs_schema_access ON {LINK_TABLE}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
)
schema_editor.execute(
@@ -44,7 +44,7 @@ def install(apps, schema_editor):
CREATE CONSTRAINT TRIGGER apa_agent_article_pair
AFTER INSERT OR UPDATE ON {LINK_TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
- custocrm.enforce_tenant_pair(
+ chatballs.enforce_tenant_pair(
'ai_aiagent', 'aiagent_id',
'support_portals_portalarticle', 'portalarticle_id'
);
@@ -55,7 +55,7 @@ def install(apps, schema_editor):
CREATE CONSTRAINT TRIGGER apa_fragment_article
AFTER INSERT OR UPDATE ON ai_knowledgefragment
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
- custocrm.enforce_tenant_fk(
+ chatballs.enforce_tenant_fk(
'support_portals_portalarticle', 'portal_article_id'
);
"""
@@ -69,8 +69,8 @@ def remove(apps, schema_editor):
schema_editor.execute(f"DROP TRIGGER IF EXISTS apa_agent_article_pair ON {LINK_TABLE}")
schema_editor.execute(
f"""
- DROP POLICY IF EXISTS custocrm_tenant_isolation ON {LINK_TABLE};
- DROP POLICY IF EXISTS custocrm_schema_access ON {LINK_TABLE};
+ DROP POLICY IF EXISTS chatballs_tenant_isolation ON {LINK_TABLE};
+ DROP POLICY IF EXISTS chatballs_schema_access ON {LINK_TABLE};
ALTER TABLE {LINK_TABLE} NO FORCE ROW LEVEL SECURITY;
ALTER TABLE {LINK_TABLE} DISABLE ROW LEVEL SECURITY;
"""
diff --git a/apps/backend/hub_platform/tenancy/migrations/0015_web_chat_widget_ingress.py b/apps/backend/chatballs/tenancy/migrations/0015_web_chat_widget_ingress.py
similarity index 59%
rename from apps/backend/hub_platform/tenancy/migrations/0015_web_chat_widget_ingress.py
rename to apps/backend/chatballs/tenancy/migrations/0015_web_chat_widget_ingress.py
index e973cee..c22396b 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0015_web_chat_widget_ingress.py
+++ b/apps/backend/chatballs/tenancy/migrations/0015_web_chat_widget_ingress.py
@@ -2,34 +2,34 @@ from django.db import migrations
FORWARD_SQL = """
-ALTER TABLE webchat_webchatwidget OWNER TO custocrm_schema;
+ALTER TABLE webchat_webchatwidget OWNER TO chatballs_schema;
ALTER TABLE webchat_webchatwidget ENABLE ROW LEVEL SECURITY;
ALTER TABLE webchat_webchatwidget FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE webchat_webchatwidget FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE
- ON webchat_webchatwidget TO custocrm_runtime_app;
-GRANT ALL ON webchat_webchatwidget TO custocrm_schema;
+ ON webchat_webchatwidget TO chatballs_runtime_app;
+GRANT ALL ON webchat_webchatwidget TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE webchat_webchatwidget_id_seq
- TO custocrm_runtime_app, custocrm_schema;
+ TO chatballs_runtime_app, chatballs_schema;
-CREATE POLICY custocrm_tenant_isolation ON webchat_webchatwidget
- FOR ALL TO custocrm_runtime_app
- USING (organization_id = custocrm.current_organization_id())
- WITH CHECK (organization_id = custocrm.current_organization_id());
-CREATE POLICY custocrm_schema_access ON webchat_webchatwidget
- FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
+CREATE POLICY chatballs_tenant_isolation ON webchat_webchatwidget
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+CREATE POLICY chatballs_schema_access ON webchat_webchatwidget
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
CREATE CONSTRAINT TRIGGER wcw_integration
AFTER INSERT OR UPDATE ON webchat_webchatwidget
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
-custocrm.enforce_tenant_fk('integrations_integration', 'integration_id');
+chatballs.enforce_tenant_fk('integrations_integration', 'integration_id');
CREATE CONSTRAINT TRIGGER ws_widget
AFTER INSERT OR UPDATE ON webchat_websession
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
-custocrm.enforce_tenant_fk('webchat_webchatwidget', 'widget_id');
+chatballs.enforce_tenant_fk('webchat_webchatwidget', 'widget_id');
-CREATE VIEW custocrm.web_widget_directory
+CREATE VIEW chatballs.web_widget_directory
WITH (security_barrier = true) AS
SELECT widget.id AS resource_id,
widget.organization_id,
@@ -46,23 +46,23 @@ WITH (security_barrier = true) AS
AND integration.status = 'OK'
AND integration.is_active
AND channel.is_active;
-ALTER VIEW custocrm.web_widget_directory OWNER TO custocrm_schema;
-REVOKE ALL ON custocrm.web_widget_directory FROM PUBLIC;
-GRANT SELECT ON custocrm.web_widget_directory TO custocrm_runtime_platform;
+ALTER VIEW chatballs.web_widget_directory OWNER TO chatballs_schema;
+REVOKE ALL ON chatballs.web_widget_directory FROM PUBLIC;
+GRANT SELECT ON chatballs.web_widget_directory TO chatballs_runtime_platform;
"""
REVERSE_SQL = """
-DROP VIEW IF EXISTS custocrm.web_widget_directory;
+DROP VIEW IF EXISTS chatballs.web_widget_directory;
DROP TRIGGER IF EXISTS ws_widget ON webchat_websession;
DROP TRIGGER IF EXISTS wcw_integration ON webchat_webchatwidget;
-DROP POLICY IF EXISTS custocrm_tenant_isolation ON webchat_webchatwidget;
-DROP POLICY IF EXISTS custocrm_schema_access ON webchat_webchatwidget;
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON webchat_webchatwidget;
+DROP POLICY IF EXISTS chatballs_schema_access ON webchat_webchatwidget;
ALTER TABLE webchat_webchatwidget NO FORCE ROW LEVEL SECURITY;
ALTER TABLE webchat_webchatwidget DISABLE ROW LEVEL SECURITY;
-REVOKE ALL ON webchat_webchatwidget FROM custocrm_runtime_app;
+REVOKE ALL ON webchat_webchatwidget FROM chatballs_runtime_app;
REVOKE USAGE, SELECT ON SEQUENCE webchat_webchatwidget_id_seq
- FROM custocrm_runtime_app;
+ FROM chatballs_runtime_app;
"""
diff --git a/apps/backend/hub_platform/tenancy/migrations/0016_support_portal_widget_guards.py b/apps/backend/chatballs/tenancy/migrations/0016_support_portal_widget_guards.py
similarity index 85%
rename from apps/backend/hub_platform/tenancy/migrations/0016_support_portal_widget_guards.py
rename to apps/backend/chatballs/tenancy/migrations/0016_support_portal_widget_guards.py
index c764ceb..35e856c 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0016_support_portal_widget_guards.py
+++ b/apps/backend/chatballs/tenancy/migrations/0016_support_portal_widget_guards.py
@@ -5,12 +5,12 @@ FORWARD_SQL = """
CREATE CONSTRAINT TRIGGER sp_portal_widget
AFTER INSERT OR UPDATE ON support_portals_supportportal
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
-custocrm.enforce_tenant_fk('webchat_webchatwidget', 'widget_id');
+chatballs.enforce_tenant_fk('webchat_webchatwidget', 'widget_id');
CREATE CONSTRAINT TRIGGER sp_product_support_widget
AFTER INSERT OR UPDATE ON support_portals_supportportalproduct
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
-custocrm.enforce_tenant_fk('webchat_webchatwidget', 'support_widget_id');
+chatballs.enforce_tenant_fk('webchat_webchatwidget', 'support_widget_id');
"""
diff --git a/apps/backend/hub_platform/tenancy/migrations/0017_drop_commerce.py b/apps/backend/chatballs/tenancy/migrations/0017_drop_commerce.py
similarity index 96%
rename from apps/backend/hub_platform/tenancy/migrations/0017_drop_commerce.py
rename to apps/backend/chatballs/tenancy/migrations/0017_drop_commerce.py
index 2002293..b13c9ad 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0017_drop_commerce.py
+++ b/apps/backend/chatballs/tenancy/migrations/0017_drop_commerce.py
@@ -25,7 +25,7 @@ REMOVED_APPS = ("sales", "orders")
def drop_commerce(apps, schema_editor):
for view in DROP_VIEWS:
- schema_editor.execute(f"DROP VIEW IF EXISTS custocrm.{view}")
+ schema_editor.execute(f"DROP VIEW IF EXISTS chatballs.{view}")
for table in DROP_TABLES:
schema_editor.execute(f'DROP TABLE IF EXISTS "{table}" CASCADE')
schema_editor.execute(
diff --git a/apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py b/apps/backend/chatballs/tenancy/migrations/0018_employee_groups_rls.py
similarity index 78%
rename from apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py
rename to apps/backend/chatballs/tenancy/migrations/0018_employee_groups_rls.py
index 923ff85..876a7b0 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0018_employee_groups_rls.py
+++ b/apps/backend/chatballs/tenancy/migrations/0018_employee_groups_rls.py
@@ -6,22 +6,22 @@ from django.db import migrations
GROUP_TABLES = ("identity_employeegroup", "identity_employeegroupmember")
FORWARD_RLS = """
-ALTER TABLE {table} OWNER TO custocrm_schema;
+ALTER TABLE {table} OWNER TO chatballs_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;
-GRANT ALL ON {table} TO custocrm_schema;
+GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
+GRANT ALL ON {table} TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq
- TO custocrm_runtime_app, custocrm_schema;
-DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
-CREATE POLICY custocrm_tenant_isolation ON {table}
- FOR ALL TO custocrm_runtime_app
- 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);
+ TO chatballs_runtime_app, chatballs_schema;
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+DROP POLICY IF EXISTS chatballs_schema_access ON {table};
+CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
# (child, parent, fk_column) — как в 0002_cross_tenant_constraints.
@@ -43,7 +43,7 @@ def apply_guards(apps, schema_editor):
schema_editor.execute(
f"CREATE CONSTRAINT TRIGGER c04_group_tfk_{index} AFTER INSERT OR UPDATE ON {child} "
"DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "
- f"custocrm.enforce_tenant_fk('{parent}', '{fk_column}')"
+ f"chatballs.enforce_tenant_fk('{parent}', '{fk_column}')"
)
# Стейл-записи contenttypes/permissions удалённых моделей (иначе migrate
# --noinput оставляет их навсегда).
@@ -68,8 +68,8 @@ def remove_guards(apps, schema_editor):
for index, (child, *_rest) in enumerate(GROUP_FOREIGN_KEYS):
schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_group_tfk_{index} ON {child}")
for table in GROUP_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"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}")
+ schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_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")
diff --git a/apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py b/apps/backend/chatballs/tenancy/migrations/0019_support_portal_directory_without_department.py
similarity index 74%
rename from apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py
rename to apps/backend/chatballs/tenancy/migrations/0019_support_portal_directory_without_department.py
index 5be386c..d7778a3 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0019_support_portal_directory_without_department.py
+++ b/apps/backend/chatballs/tenancy/migrations/0019_support_portal_directory_without_department.py
@@ -1,12 +1,12 @@
# ADR-HUB-0043: у портала больше нет отдела. Дроп колонки department_id
-# (support_portals/0008) каскадно унёс вьюху custocrm.support_portal_directory —
+# (support_portals/0008) каскадно унёс вьюху chatballs.support_portal_directory —
# пересоздаём её без join на отделы и снимаем стейл-триггер sp_portal_department.
from django.db import migrations
FORWARD_SQL = """
DROP TRIGGER IF EXISTS sp_portal_department ON support_portals_supportportal;
-DROP VIEW IF EXISTS custocrm.support_portal_directory;
-CREATE VIEW custocrm.support_portal_directory
+DROP VIEW IF EXISTS chatballs.support_portal_directory;
+CREATE VIEW chatballs.support_portal_directory
WITH (security_barrier = true) AS
SELECT portal.id AS resource_id,
portal.organization_id,
@@ -21,9 +21,9 @@ WITH (security_barrier = true) AS
WHERE portal.status = 'PUBLISHED'
AND portal.custom_domain <> ''
AND portal.custom_domain_verified_at IS NOT NULL;
-ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema;
-REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC;
-GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform;
+ALTER VIEW chatballs.support_portal_directory OWNER TO chatballs_schema;
+REVOKE ALL ON chatballs.support_portal_directory FROM PUBLIC;
+GRANT SELECT ON chatballs.support_portal_directory TO chatballs_runtime_platform;
"""
# На откате колонка department_id ещё не восстановлена (support_portals/0008
@@ -31,8 +31,8 @@ GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform;
# нельзя. Оставляем безотдельную: reverse tenancy/0011 делает DROP VIEW без
# IF EXISTS и требует, чтобы вьюха существовала.
REVERSE_SQL = """
-DROP VIEW IF EXISTS custocrm.support_portal_directory;
-CREATE VIEW custocrm.support_portal_directory
+DROP VIEW IF EXISTS chatballs.support_portal_directory;
+CREATE VIEW chatballs.support_portal_directory
WITH (security_barrier = true) AS
SELECT portal.id AS resource_id,
portal.organization_id,
@@ -47,9 +47,9 @@ WITH (security_barrier = true) AS
WHERE portal.status = 'PUBLISHED'
AND portal.custom_domain <> ''
AND portal.custom_domain_verified_at IS NOT NULL;
-ALTER VIEW custocrm.support_portal_directory OWNER TO custocrm_schema;
-REVOKE ALL ON custocrm.support_portal_directory FROM PUBLIC;
-GRANT SELECT ON custocrm.support_portal_directory TO custocrm_runtime_platform;
+ALTER VIEW chatballs.support_portal_directory OWNER TO chatballs_schema;
+REVOKE ALL ON chatballs.support_portal_directory FROM PUBLIC;
+GRANT SELECT ON chatballs.support_portal_directory TO chatballs_runtime_platform;
"""
diff --git a/apps/backend/hub_platform/tenancy/migrations/0020_drop_billing.py b/apps/backend/chatballs/tenancy/migrations/0020_drop_billing.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/migrations/0020_drop_billing.py
rename to apps/backend/chatballs/tenancy/migrations/0020_drop_billing.py
diff --git a/apps/backend/hub_platform/tenancy/migrations/0021_chat_extras_guards.py b/apps/backend/chatballs/tenancy/migrations/0021_chat_extras_guards.py
similarity index 61%
rename from apps/backend/hub_platform/tenancy/migrations/0021_chat_extras_guards.py
rename to apps/backend/chatballs/tenancy/migrations/0021_chat_extras_guards.py
index 0f6279d..66de005 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0021_chat_extras_guards.py
+++ b/apps/backend/chatballs/tenancy/migrations/0021_chat_extras_guards.py
@@ -7,42 +7,42 @@ ORG_TABLES = ("conversations_conversationlabel", "conversations_replytemplate")
M2M_TABLE = "conversations_conversation_labels"
FORWARD_RLS = """
-ALTER TABLE {table} OWNER TO custocrm_schema;
+ALTER TABLE {table} OWNER TO chatballs_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;
-GRANT ALL ON {table} TO custocrm_schema;
+GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
+GRANT ALL ON {table} TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq
- TO custocrm_runtime_app, custocrm_schema;
-DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
-CREATE POLICY custocrm_tenant_isolation ON {table}
- FOR ALL TO custocrm_runtime_app
- 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);
+ TO chatballs_runtime_app, chatballs_schema;
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+DROP POLICY IF EXISTS chatballs_schema_access ON {table};
+CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
M2M_RLS = f"""
-ALTER TABLE {M2M_TABLE} OWNER TO custocrm_schema;
+ALTER TABLE {M2M_TABLE} OWNER TO chatballs_schema;
ALTER TABLE {M2M_TABLE} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {M2M_TABLE} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON {M2M_TABLE} FROM PUBLIC;
-GRANT SELECT, INSERT, UPDATE, DELETE ON {M2M_TABLE} TO custocrm_runtime_app;
-GRANT ALL ON {M2M_TABLE} TO custocrm_schema;
+GRANT SELECT, INSERT, UPDATE, DELETE ON {M2M_TABLE} TO chatballs_runtime_app;
+GRANT ALL ON {M2M_TABLE} TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE {M2M_TABLE}_id_seq
- TO custocrm_runtime_app, custocrm_schema;
-DROP POLICY IF EXISTS custocrm_tenant_isolation ON {M2M_TABLE};
-CREATE POLICY custocrm_tenant_isolation ON {M2M_TABLE}
- FOR ALL TO custocrm_runtime_app
+ TO chatballs_runtime_app, chatballs_schema;
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON {M2M_TABLE};
+CREATE POLICY chatballs_tenant_isolation ON {M2M_TABLE}
+ FOR ALL TO chatballs_runtime_app
USING (
EXISTS (
SELECT 1 FROM conversations_conversation conversation
JOIN conversations_conversationlabel label ON label.id = conversationlabel_id
WHERE conversation.id = conversation_id
- AND conversation.organization_id = custocrm.current_organization_id()
+ AND conversation.organization_id = chatballs.current_organization_id()
AND label.organization_id = conversation.organization_id
)
)
@@ -51,13 +51,13 @@ CREATE POLICY custocrm_tenant_isolation ON {M2M_TABLE}
SELECT 1 FROM conversations_conversation conversation
JOIN conversations_conversationlabel label ON label.id = conversationlabel_id
WHERE conversation.id = conversation_id
- AND conversation.organization_id = custocrm.current_organization_id()
+ AND conversation.organization_id = chatballs.current_organization_id()
AND label.organization_id = conversation.organization_id
)
);
-DROP POLICY IF EXISTS custocrm_schema_access ON {M2M_TABLE};
-CREATE POLICY custocrm_schema_access ON {M2M_TABLE}
- FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
+DROP POLICY IF EXISTS chatballs_schema_access ON {M2M_TABLE};
+CREATE POLICY chatballs_schema_access ON {M2M_TABLE}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
@@ -71,7 +71,7 @@ def apply_guards(apps, schema_editor):
schema_editor.execute(
f"CREATE CONSTRAINT TRIGGER c04_label_tpair AFTER INSERT OR UPDATE ON {M2M_TABLE} "
"DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "
- "custocrm.enforce_tenant_pair('conversations_conversation', 'conversation_id', "
+ "chatballs.enforce_tenant_pair('conversations_conversation', 'conversation_id', "
"'conversations_conversationlabel', 'conversationlabel_id')"
)
@@ -79,8 +79,8 @@ def apply_guards(apps, schema_editor):
def remove_guards(apps, schema_editor):
schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_label_tpair ON {M2M_TABLE}")
for table in (*ORG_TABLES, M2M_TABLE):
- 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"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}")
+ schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_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")
diff --git a/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py b/apps/backend/chatballs/tenancy/migrations/0022_demo_registry_guards.py
similarity index 60%
rename from apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py
rename to apps/backend/chatballs/tenancy/migrations/0022_demo_registry_guards.py
index 0f362a7..b7a87bb 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py
+++ b/apps/backend/chatballs/tenancy/migrations/0022_demo_registry_guards.py
@@ -7,23 +7,23 @@ from django.db import migrations
TABLES = ("identity_demodataset", "identity_demorecord")
FORWARD_RLS = """
-ALTER TABLE {table} OWNER TO custocrm_schema;
+ALTER TABLE {table} OWNER TO chatballs_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;
+ TO chatballs_runtime_app, chatballs_runtime_platform;
+GRANT ALL ON {table} TO chatballs_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);
+ TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema;
+DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
+CREATE POLICY chatballs_tenant_isolation ON {table}
+ FOR ALL TO chatballs_runtime_app, chatballs_runtime_platform
+ USING (organization_id = chatballs.current_organization_id())
+ WITH CHECK (organization_id = chatballs.current_organization_id());
+DROP POLICY IF EXISTS chatballs_schema_access ON {table};
+CREATE POLICY chatballs_schema_access ON {table}
+ FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
"""
@@ -34,8 +34,8 @@ def apply_guards(apps, schema_editor):
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"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}")
+ schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_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")
diff --git a/apps/backend/hub_platform/tenancy/migrations/0023_user_delete_grants.py b/apps/backend/chatballs/tenancy/migrations/0023_user_delete_grants.py
similarity index 88%
rename from apps/backend/hub_platform/tenancy/migrations/0023_user_delete_grants.py
rename to apps/backend/chatballs/tenancy/migrations/0023_user_delete_grants.py
index e3c53d4..5cd1a23 100644
--- a/apps/backend/hub_platform/tenancy/migrations/0023_user_delete_grants.py
+++ b/apps/backend/chatballs/tenancy/migrations/0023_user_delete_grants.py
@@ -6,12 +6,12 @@ from django.db import migrations
FORWARD = """
GRANT SELECT, DELETE ON django_admin_log, identity_humanuser_groups, identity_humanuser_user_permissions
- TO custocrm_runtime_app, custocrm_runtime_platform;
+ TO chatballs_runtime_app, chatballs_runtime_platform;
"""
BACKWARD = """
REVOKE DELETE ON django_admin_log, identity_humanuser_groups, identity_humanuser_user_permissions
- FROM custocrm_runtime_app, custocrm_runtime_platform;
+ FROM chatballs_runtime_app, chatballs_runtime_platform;
"""
diff --git a/apps/backend/hub_platform/tenancy/migrations/0024_user_fk_set_null.py b/apps/backend/chatballs/tenancy/migrations/0024_user_fk_set_null.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/migrations/0024_user_fk_set_null.py
rename to apps/backend/chatballs/tenancy/migrations/0024_user_fk_set_null.py
diff --git a/apps/backend/hub_platform/tenancy/migrations/__init__.py b/apps/backend/chatballs/tenancy/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/migrations/__init__.py
rename to apps/backend/chatballs/tenancy/migrations/__init__.py
diff --git a/apps/backend/hub_platform/tenancy/models.py b/apps/backend/chatballs/tenancy/models.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/models.py
rename to apps/backend/chatballs/tenancy/models.py
diff --git a/apps/backend/hub_platform/tenancy/routing.py b/apps/backend/chatballs/tenancy/routing.py
similarity index 100%
rename from apps/backend/hub_platform/tenancy/routing.py
rename to apps/backend/chatballs/tenancy/routing.py
diff --git a/apps/backend/hub_platform/tenancy/storage.py b/apps/backend/chatballs/tenancy/storage.py
similarity index 85%
rename from apps/backend/hub_platform/tenancy/storage.py
rename to apps/backend/chatballs/tenancy/storage.py
index 0e21b05..21dc513 100644
--- a/apps/backend/hub_platform/tenancy/storage.py
+++ b/apps/backend/chatballs/tenancy/storage.py
@@ -3,8 +3,8 @@ from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import transaction
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.models import OrganizationStorageUsage
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.models import OrganizationStorageUsage
STORAGE_QUOTA_KEY = "storage_bytes"
diff --git a/apps/backend/hub_platform/tenancy/storage_backends.py b/apps/backend/chatballs/tenancy/storage_backends.py
similarity index 96%
rename from apps/backend/hub_platform/tenancy/storage_backends.py
rename to apps/backend/chatballs/tenancy/storage_backends.py
index 60b8aa9..65313bb 100644
--- a/apps/backend/hub_platform/tenancy/storage_backends.py
+++ b/apps/backend/chatballs/tenancy/storage_backends.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
from django.core.files.storage import FileSystemStorage
-from hub_platform.tenancy.database import current_tenant_id
+from chatballs.tenancy.database import current_tenant_id
def _assert_tenant_key(name: str) -> None:
@@ -11,7 +11,7 @@ def _assert_tenant_key(name: str) -> None:
if organization_id is None:
raise SuspiciousFileOperation("Tenant storage access requires database context")
- from hub_platform.identity.models import Organization
+ from chatballs.identity.models import Organization
try:
public_id = Organization.objects.values_list("public_id", flat=True).get(
diff --git a/apps/backend/hub_platform/tenancy/storage_quota.py b/apps/backend/chatballs/tenancy/storage_quota.py
similarity index 96%
rename from apps/backend/hub_platform/tenancy/storage_quota.py
rename to apps/backend/chatballs/tenancy/storage_quota.py
index 3287d90..2ee46a2 100644
--- a/apps/backend/hub_platform/tenancy/storage_quota.py
+++ b/apps/backend/chatballs/tenancy/storage_quota.py
@@ -3,8 +3,8 @@ from __future__ import annotations
from django.db import transaction
from django.db.models import F
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.models import OrganizationStorageUsage, StorageReservation
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.models import OrganizationStorageUsage, StorageReservation
@transaction.atomic
diff --git a/apps/backend/hub_platform/tenancy/test_media_migration.py b/apps/backend/chatballs/tenancy/test_media_migration.py
similarity index 92%
rename from apps/backend/hub_platform/tenancy/test_media_migration.py
rename to apps/backend/chatballs/tenancy/test_media_migration.py
index 1838c1b..43802d1 100644
--- a/apps/backend/hub_platform/tenancy/test_media_migration.py
+++ b/apps/backend/chatballs/tenancy/test_media_migration.py
@@ -6,10 +6,10 @@ from pathlib import Path
from django.core.management import call_command
from django.test import TestCase, override_settings
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.ai.models import Knowledge, KnowledgeAttachment
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.models import OrganizationStorageUsage
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.ai.models import Knowledge, KnowledgeAttachment
+from chatballs.identity.models import Organization
+from chatballs.tenancy.models import OrganizationStorageUsage
_DESTINATION_ROOT = Path(tempfile.mkdtemp(prefix="c04-destination-media-"))
diff --git a/apps/backend/hub_platform/tenancy/test_platform_grants.py b/apps/backend/chatballs/tenancy/test_platform_grants.py
similarity index 84%
rename from apps/backend/hub_platform/tenancy/test_platform_grants.py
rename to apps/backend/chatballs/tenancy/test_platform_grants.py
index 5f7f774..a69b63f 100644
--- a/apps/backend/hub_platform/tenancy/test_platform_grants.py
+++ b/apps/backend/chatballs/tenancy/test_platform_grants.py
@@ -3,19 +3,19 @@ from __future__ import annotations
from django.db import transaction
from django.test import TestCase
-from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
-from hub_platform.ai.models import KnowledgeCategory
-from hub_platform.identity.group_models import EmployeeGroup
-from hub_platform.identity.models import (
+from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
+from chatballs.ai.models import KnowledgeCategory
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.platform.provisioning_command import ProvisioningCommand
-from hub_platform.platform.provisioning_service import provision_organization
-from hub_platform.platform.testing import create_platform_operator
-from hub_platform.tenancy.database import current_tenant_id, tenant_atomic
+from chatballs.platform.provisioning_command import ProvisioningCommand
+from chatballs.platform.provisioning_service import provision_organization
+from chatballs.platform.testing import create_platform_operator
+from chatballs.tenancy.database import current_tenant_id, tenant_atomic
class PlatformProvisioningGrantsTests(TestCase):
diff --git a/apps/backend/chatballs/tenancy/test_rls.py b/apps/backend/chatballs/tenancy/test_rls.py
new file mode 100644
index 0000000..6c5a1ec
--- /dev/null
+++ b/apps/backend/chatballs/tenancy/test_rls.py
@@ -0,0 +1,266 @@
+from django.db import DatabaseError, connection, transaction
+from django.test import TransactionTestCase
+
+from chatballs.ai.knowledge_categories import ensure_uncategorized_category
+from chatballs.ai.models import AIAgent, Knowledge
+from chatballs.channels.models import Channel
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.identity.models import (
+ AuditEvent,
+ AuditResult,
+ EmployeeRole,
+ HumanUser,
+ Organization,
+ OrganizationMembership,
+)
+from chatballs.products.models import Product
+from chatballs.tenancy.database import current_tenant_id, set_local_tenant
+from chatballs.tenancy.models import StorageReservation
+from chatballs.testing import TenantAPIClient
+
+
+class RowLevelSecurityTests(TransactionTestCase):
+ reset_sequences = True
+
+ def setUp(self) -> None:
+ self.first = Organization.objects.create(name="First", slug="rls-first")
+ self.second = Organization.objects.create(name="Second", slug="rls-second")
+ self.first_product = Product.objects.create(
+ organization=self.first,
+ code="first",
+ name="First product",
+ )
+ self.second_product = Product.objects.create(
+ organization=self.second,
+ code="second",
+ name="Second product",
+ )
+ self.second_group = EmployeeGroup.objects.create(
+ organization=self.second,
+ name="Foreign",
+ )
+ self.user = HumanUser.objects.create_user(email="rls@example.test")
+ self.membership = OrganizationMembership.objects.create(
+ organization=self.first,
+ user=self.user,
+ role=EmployeeRole.OWNER,
+ position_title="Owner",
+ )
+
+ @staticmethod
+ def _set_role(role: str) -> None:
+ if role not in {"chatballs_runtime_app", "chatballs_runtime_platform"}:
+ raise ValueError("Unexpected test role")
+ with connection.cursor() as cursor:
+ cursor.execute(f"SET LOCAL ROLE {role}")
+
+ def test_runtime_roles_are_not_owners_or_bypassrls(self) -> None:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "SELECT rolname, rolsuper, rolbypassrls, "
+ "pg_has_role(rolname, 'chatballs_schema', 'MEMBER') FROM pg_roles "
+ "WHERE rolname IN ('chatballs_runtime_app', 'chatballs_runtime_platform') "
+ "ORDER BY rolname"
+ )
+ roles = cursor.fetchall()
+ cursor.execute(
+ "SELECT tableowner FROM pg_tables "
+ "WHERE schemaname = 'public' AND tablename = 'identity_product'"
+ )
+ owner = cursor.fetchone()[0]
+ self.assertEqual(len(roles), 2)
+ self.assertTrue(
+ all(
+ not superuser and not bypass and not schema_member
+ for _, superuser, bypass, schema_member in roles
+ )
+ )
+ self.assertEqual(owner, "chatballs_schema")
+
+ def test_app_role_is_fail_closed_and_scoped(self) -> None:
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ self.assertEqual(Product.objects.count(), 0)
+
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ self.assertEqual(
+ list(Product.objects.values_list("code", flat=True)), ["first"]
+ )
+ Product.objects.create(
+ organization_id=self.first.id,
+ code="created",
+ name="Created",
+ )
+ self.assertEqual(
+ Product.objects.filter(code="created").update(name="Updated"), 1
+ )
+ self.assertEqual(Product.objects.filter(code="created").delete()[0], 1)
+
+ with self.assertRaises(DatabaseError), transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ Product.objects.create(
+ organization_id=self.second.id,
+ code="forged",
+ name="Forged",
+ )
+
+ def test_app_role_updates_only_current_organization(self) -> None:
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ self.assertEqual(
+ Organization.objects.filter(id=self.first.id).update(
+ name="Updated first"
+ ),
+ 1,
+ )
+ self.assertEqual(
+ Organization.objects.filter(id=self.second.id).update(
+ name="Forged second"
+ ),
+ 0,
+ )
+
+ self.first.refresh_from_db()
+ self.second.refresh_from_db()
+ self.assertEqual(self.first.name, "Updated first")
+ self.assertEqual(self.second.name, "Second")
+
+ def test_app_role_manages_only_own_storage_reservations(self) -> None:
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ reservation = StorageReservation.objects.create(
+ organization=self.first,
+ idempotency_key="rls-storage",
+ reserved_bytes=128,
+ )
+ self.assertIsNotNone(reservation.pk)
+ self.assertEqual(
+ StorageReservation.objects.filter(organization=self.second).count(),
+ 0,
+ )
+
+ with self.assertRaises(DatabaseError), transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ StorageReservation.objects.create(
+ organization=self.second,
+ idempotency_key="rls-forged-storage",
+ reserved_bytes=128,
+ )
+
+ def test_app_role_writes_platform_audit_event_without_tenant_context(self) -> None:
+ # Login-failed audit path: app role, no tenant context, organization NULL.
+ # ORM create() issues INSERT ... RETURNING id, which also requires SELECT
+ # visibility of the new row (tenancy.0007 policies).
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ event = AuditEvent.objects.create(
+ action="identity.login_failed",
+ object_type="HumanUser",
+ result=AuditResult.DENIED,
+ correlation_id="rls-null-org-audit",
+ )
+ self.assertIsNotNone(event.pk)
+
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ self.assertEqual(
+ AuditEvent.objects.filter(organization__isnull=False).count(), 0
+ )
+
+ def test_cross_tenant_relation_is_rejected_by_database_trigger(self) -> None:
+ with self.assertRaises(DatabaseError), transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "INSERT INTO identity_employeegroupmember "
+ "(organization_id, group_id, employee_id, created_at) "
+ "VALUES (%s, %s, %s, NOW())",
+ [self.first.id, self.second_group.id, self.membership.id],
+ )
+
+ def test_cross_tenant_many_to_many_is_rejected(self) -> None:
+ channel = Channel.objects.create(
+ organization=self.first,
+ code="rls-agent",
+ name="RLS agent",
+ )
+ agent = AIAgent.objects.create(
+ organization=self.first,
+ channel=channel,
+ name="RLS agent",
+ )
+ foreign_knowledge = Knowledge.objects.create(
+ organization=self.second,
+ category=ensure_uncategorized_category(self.second),
+ title="Foreign knowledge",
+ )
+
+ with self.assertRaises(DatabaseError), transaction.atomic():
+ self._set_role("chatballs_runtime_app")
+ set_local_tenant(self.first.id)
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "INSERT INTO ai_aiagent_knowledge_items (aiagent_id, knowledge_id) "
+ "VALUES (%s, %s)",
+ [agent.id, foreign_knowledge.id],
+ )
+
+ def test_http_request_runs_with_runtime_role_and_tenant_middleware(self) -> None:
+ client = TenantAPIClient()
+ client.force_authenticate(self.user)
+ with connection.cursor() as cursor:
+ cursor.execute("SET ROLE chatballs_runtime_app")
+ try:
+ own = client.get(
+ f"/api/v1/organizations/{self.first.public_id}/company/products/"
+ )
+ foreign = client.get(
+ f"/api/v1/organizations/{self.second.public_id}/company/products/"
+ )
+ finally:
+ with connection.cursor() as cursor:
+ cursor.execute("RESET ROLE")
+ self.assertEqual(own.status_code, 200)
+ self.assertEqual([item["code"] for item in own.json()["items"]], ["first"])
+ self.assertEqual(foreign.status_code, 404)
+
+ def test_platform_role_can_only_use_ingress_directory(self) -> None:
+ with self.assertRaises(DatabaseError), transaction.atomic():
+ self._set_role("chatballs_runtime_platform")
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT id FROM identity_product LIMIT 1")
+
+ # Ingress-вьюха продаж удалена (ADR-HUB-0041) — используем membership_directory.
+ with transaction.atomic():
+ self._set_role("chatballs_runtime_platform")
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "SELECT organization_id, resource_id "
+ "FROM chatballs.membership_directory WHERE user_id = %s",
+ [self.user.id],
+ )
+ row = cursor.fetchone()
+ self.assertIsNotNone(row)
+ self.assertEqual(row[0], self.first.id)
+
+ def test_transaction_local_context_clears_after_commit_and_rollback(self) -> None:
+ with transaction.atomic():
+ set_local_tenant(self.first.id)
+ self.assertEqual(current_tenant_id(), self.first.id)
+ self.assertIsNone(current_tenant_id())
+
+ try:
+ with transaction.atomic():
+ set_local_tenant(self.second.id)
+ self.assertEqual(current_tenant_id(), self.second.id)
+ raise RuntimeError("rollback")
+ except RuntimeError:
+ pass
+ self.assertIsNone(current_tenant_id())
diff --git a/apps/backend/hub_platform/tenancy/test_storage_quota.py b/apps/backend/chatballs/tenancy/test_storage_quota.py
similarity index 94%
rename from apps/backend/hub_platform/tenancy/test_storage_quota.py
rename to apps/backend/chatballs/tenancy/test_storage_quota.py
index 754d2cb..9fe4498 100644
--- a/apps/backend/hub_platform/tenancy/test_storage_quota.py
+++ b/apps/backend/chatballs/tenancy/test_storage_quota.py
@@ -2,14 +2,14 @@ from __future__ import annotations
from django.test import TestCase
-from hub_platform.identity.models import Organization
-from hub_platform.tenancy.models import OrganizationStorageUsage, StorageReservation
-from hub_platform.tenancy.storage_quota import (
+from chatballs.identity.models import Organization
+from chatballs.tenancy.models import OrganizationStorageUsage, StorageReservation
+from chatballs.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
)
-from hub_platform.testing import system_tenant_context
+from chatballs.testing import system_tenant_context
class StorageReserveFinalizeReleaseTests(TestCase):
diff --git a/apps/backend/hub_platform/tenancy/tests.py b/apps/backend/chatballs/tenancy/tests.py
similarity index 94%
rename from apps/backend/hub_platform/tenancy/tests.py
rename to apps/backend/chatballs/tenancy/tests.py
index 642089c..4342a0f 100644
--- a/apps/backend/hub_platform/tenancy/tests.py
+++ b/apps/backend/chatballs/tenancy/tests.py
@@ -4,18 +4,18 @@ from django.db import DatabaseError, IntegrityError, transaction
from django.test import TestCase
from rest_framework.test import APIClient as RawAPIClient
-from hub_platform.events.models import EventOwnership
-from hub_platform.events.services import DomainEvent, enqueue_event, tenant_context_for_event
-from hub_platform.identity.group_models import EmployeeGroup
-from hub_platform.identity.models import (
+from chatballs.events.models import EventOwnership
+from chatballs.events.services import DomainEvent, enqueue_event, tenant_context_for_event
+from chatballs.identity.group_models import EmployeeGroup
+from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
-from hub_platform.products.models import Product
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.testing import TenantAPIClient
+from chatballs.products.models import Product
+from chatballs.tenancy.context import TenantContext
+from chatballs.testing import TenantAPIClient
class TenantHttpBoundaryTests(TestCase):
diff --git a/apps/backend/hub_platform/testing.py b/apps/backend/chatballs/testing.py
similarity index 95%
rename from apps/backend/hub_platform/testing.py
rename to apps/backend/chatballs/testing.py
index 0227fc0..9bc6e77 100644
--- a/apps/backend/hub_platform/testing.py
+++ b/apps/backend/chatballs/testing.py
@@ -2,8 +2,8 @@ from __future__ import annotations
from rest_framework.test import APIClient
-from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
-from hub_platform.tenancy.context import TenantActorKind, TenantContext
+from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
+from chatballs.tenancy.context import TenantActorKind, TenantContext
def tenant_context_for(
diff --git a/apps/backend/hub_platform/webchat/__init__.py b/apps/backend/chatballs/webchat/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/webchat/__init__.py
rename to apps/backend/chatballs/webchat/__init__.py
diff --git a/apps/backend/chatballs/webchat/apps.py b/apps/backend/chatballs/webchat/apps.py
new file mode 100644
index 0000000..7de75ba
--- /dev/null
+++ b/apps/backend/chatballs/webchat/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class WebchatConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ label = "webchat"
+ name = "chatballs.webchat"
+ verbose_name = "Web chat widget"
diff --git a/apps/backend/hub_platform/webchat/loader.py b/apps/backend/chatballs/webchat/loader.py
similarity index 98%
rename from apps/backend/hub_platform/webchat/loader.py
rename to apps/backend/chatballs/webchat/loader.py
index a0f7eb6..73fda91 100644
--- a/apps/backend/hub_platform/webchat/loader.py
+++ b/apps/backend/chatballs/webchat/loader.py
@@ -28,7 +28,7 @@ LOADER_JS = r"""
var open = false, frame = null, unread = false, callActive = false, tokenProvider = null;
var api = window.ChatballsChat = window.ChatballsChat || {};
- window.CustoCRMChat = api; // legacy alias для уже встроенных хостов
+ window.ChatballsChat = api; // legacy alias для уже встроенных хостов
api._instances = api._instances || [];
api._providers = api._providers || {};
api.init = function (options) {
diff --git a/apps/backend/hub_platform/webchat/migrations/0001_initial.py b/apps/backend/chatballs/webchat/migrations/0001_initial.py
similarity index 100%
rename from apps/backend/hub_platform/webchat/migrations/0001_initial.py
rename to apps/backend/chatballs/webchat/migrations/0001_initial.py
diff --git a/apps/backend/hub_platform/webchat/migrations/0002_websession_organization.py b/apps/backend/chatballs/webchat/migrations/0002_websession_organization.py
similarity index 100%
rename from apps/backend/hub_platform/webchat/migrations/0002_websession_organization.py
rename to apps/backend/chatballs/webchat/migrations/0002_websession_organization.py
diff --git a/apps/backend/hub_platform/webchat/migrations/0003_alter_websession_organization.py b/apps/backend/chatballs/webchat/migrations/0003_alter_websession_organization.py
similarity index 100%
rename from apps/backend/hub_platform/webchat/migrations/0003_alter_websession_organization.py
rename to apps/backend/chatballs/webchat/migrations/0003_alter_websession_organization.py
diff --git a/apps/backend/hub_platform/webchat/migrations/0004_web_chat_widget.py b/apps/backend/chatballs/webchat/migrations/0004_web_chat_widget.py
similarity index 95%
rename from apps/backend/hub_platform/webchat/migrations/0004_web_chat_widget.py
rename to apps/backend/chatballs/webchat/migrations/0004_web_chat_widget.py
index dfc7c0d..9c54862 100644
--- a/apps/backend/hub_platform/webchat/migrations/0004_web_chat_widget.py
+++ b/apps/backend/chatballs/webchat/migrations/0004_web_chat_widget.py
@@ -3,7 +3,7 @@ import uuid
import django.db.models.deletion
from django.db import migrations, models
-import hub_platform.webchat.models
+import chatballs.webchat.models
def backfill_widgets(apps, schema_editor):
@@ -66,7 +66,7 @@ class Migration(migrations.Migration):
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("code", models.SlugField(max_length=64)),
- ("public_key", models.CharField(default=hub_platform.webchat.models.generate_widget_public_key, editable=False, max_length=64, unique=True)),
+ ("public_key", models.CharField(default=chatballs.webchat.models.generate_widget_public_key, editable=False, max_length=64, unique=True)),
("name", models.CharField(max_length=255)),
("mode", models.CharField(choices=[("ANONYMOUS", "Анонимный"), ("AUTHENTICATED_PRODUCT", "Авторизованный продукт")], default="ANONYMOUS", max_length=32)),
("status", models.CharField(choices=[("DRAFT", "Черновик"), ("PUBLISHED", "Опубликован"), ("DISABLED", "Отключён")], default="DRAFT", max_length=16)),
diff --git a/apps/backend/hub_platform/webchat/migrations/__init__.py b/apps/backend/chatballs/webchat/migrations/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/webchat/migrations/__init__.py
rename to apps/backend/chatballs/webchat/migrations/__init__.py
diff --git a/apps/backend/hub_platform/webchat/models.py b/apps/backend/chatballs/webchat/models.py
similarity index 98%
rename from apps/backend/hub_platform/webchat/models.py
rename to apps/backend/chatballs/webchat/models.py
index 8968a9a..8e4a545 100644
--- a/apps/backend/hub_platform/webchat/models.py
+++ b/apps/backend/chatballs/webchat/models.py
@@ -3,7 +3,7 @@ import secrets
from django.core.exceptions import ValidationError
from django.db import models
-from hub_platform.tenancy.models import TenantRelationModel
+from chatballs.tenancy.models import TenantRelationModel
def generate_widget_public_key() -> str:
diff --git a/apps/backend/hub_platform/webchat/services.py b/apps/backend/chatballs/webchat/services.py
similarity index 94%
rename from apps/backend/hub_platform/webchat/services.py
rename to apps/backend/chatballs/webchat/services.py
index 234d5c0..e87c714 100644
--- a/apps/backend/hub_platform/webchat/services.py
+++ b/apps/backend/chatballs/webchat/services.py
@@ -6,18 +6,18 @@ from urllib.parse import urlsplit
from django.conf import settings
from django.db import models, transaction
-from hub_platform.conversations.ingest import ingest_inbound
-from hub_platform.conversations.models import (
+from chatballs.conversations.ingest import ingest_inbound
+from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
Conversation,
ControlMode,
LifecycleState,
)
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.integrations.models import Integration, IntegrationProvider
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.webchat.models import WebChatWidget, WebSession
+from chatballs.conversations.transports.base import InboundMessage
+from chatballs.integrations.models import Integration, IntegrationProvider
+from chatballs.tenancy.context import TenantContext
+from chatballs.webchat.models import WebChatWidget, WebSession
DEFAULT_GREETING = "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?"
DEFAULT_CONSENT = "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение."
@@ -239,8 +239,8 @@ def post_contact(session: WebSession, phone: str) -> None:
def _call_payload(session: WebSession) -> dict | None:
# Приглашение на звонок для активной session (SPEC-HUB-0013 §7.1):
# виджет получает его этим же поллингом, без отдельного realtime-канала.
- from hub_platform.calls.serializers import public_invite_payload
- from hub_platform.calls.services import webchat_active_call
+ from chatballs.calls.serializers import public_invite_payload
+ from chatballs.calls.services import webchat_active_call
call = webchat_active_call(session.identity)
if call is None:
diff --git a/apps/backend/hub_platform/webchat/testing.py b/apps/backend/chatballs/webchat/testing.py
similarity index 93%
rename from apps/backend/hub_platform/webchat/testing.py
rename to apps/backend/chatballs/webchat/testing.py
index 9caaf49..ad9b0f2 100644
--- a/apps/backend/hub_platform/webchat/testing.py
+++ b/apps/backend/chatballs/webchat/testing.py
@@ -1,10 +1,10 @@
-from hub_platform.integrations.models import (
+from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
IntegrationStatus,
)
-from hub_platform.webchat.models import (
+from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
diff --git a/apps/backend/hub_platform/webchat/tests.py b/apps/backend/chatballs/webchat/tests.py
similarity index 94%
rename from apps/backend/hub_platform/webchat/tests.py
rename to apps/backend/chatballs/webchat/tests.py
index 52b5dab..5d8ca41 100644
--- a/apps/backend/hub_platform/webchat/tests.py
+++ b/apps/backend/chatballs/webchat/tests.py
@@ -3,11 +3,11 @@ import json
from django.test import TestCase
from rest_framework.test import APIClient
-from hub_platform.channels.models import Channel
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.webchat.models import WebSession
-from hub_platform.webchat.testing import create_web_widget
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_edevs_owner
+from chatballs.identity.models import Organization
+from chatballs.webchat.models import WebSession
+from chatballs.webchat.testing import create_web_widget
class PublicWebChatWidgetTests(TestCase):
@@ -79,7 +79,7 @@ class PublicWebChatWidgetTests(TestCase):
# оператору; аудио отдаётся только владельцу токена сессии.
from django.core.files.uploadedfile import SimpleUploadedFile
- from hub_platform.conversations.models import (
+ from chatballs.conversations.models import (
ControlMode,
Conversation,
MessageKind,
diff --git a/apps/backend/hub_platform/webchat/urls.py b/apps/backend/chatballs/webchat/urls.py
similarity index 94%
rename from apps/backend/hub_platform/webchat/urls.py
rename to apps/backend/chatballs/webchat/urls.py
index 7ce41ff..a700fb1 100644
--- a/apps/backend/hub_platform/webchat/urls.py
+++ b/apps/backend/chatballs/webchat/urls.py
@@ -1,6 +1,6 @@
from django.urls import path
-from hub_platform.webchat import views
+from chatballs.webchat import views
urlpatterns = [
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
diff --git a/apps/backend/hub_platform/webchat/views.py b/apps/backend/chatballs/webchat/views.py
similarity index 92%
rename from apps/backend/hub_platform/webchat/views.py
rename to apps/backend/chatballs/webchat/views.py
index c01aaaf..5ccda6f 100644
--- a/apps/backend/hub_platform/webchat/views.py
+++ b/apps/backend/chatballs/webchat/views.py
@@ -8,20 +8,20 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
-from hub_platform.conversations.models import Message, MessageKind
-from hub_platform.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
-from hub_platform.identity.models import Organization
-from hub_platform.integrations.models import IntegrationStatus
-from hub_platform.tenancy.context import TenantContext
-from hub_platform.tenancy.database import tenant_atomic
-from hub_platform.tenancy.ingress import (
+from chatballs.conversations.models import Message, MessageKind
+from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
+from chatballs.identity.models import Organization
+from chatballs.integrations.models import IntegrationStatus
+from chatballs.tenancy.context import TenantContext
+from chatballs.tenancy.database import tenant_atomic
+from chatballs.tenancy.ingress import (
web_channel_route,
web_session_route,
web_widget_route,
)
-from hub_platform.webchat import services
-from hub_platform.webchat.loader import LOADER_JS
-from hub_platform.webchat.models import (
+from chatballs.webchat import services
+from chatballs.webchat.loader import LOADER_JS
+from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
@@ -229,9 +229,9 @@ class WebchatContactView(_Public):
class WebchatCallOpenView(_Public):
def post(self, request: Request) -> Response:
- from hub_platform.calls.errors import CallTokenError
- from hub_platform.calls.serializers import ice_servers_payload, public_invite_payload
- from hub_platform.calls.services import open_call_for_identity
+ from chatballs.calls.errors import CallTokenError
+ from chatballs.calls.serializers import ice_servers_payload, public_invite_payload
+ from chatballs.calls.services import open_call_for_identity
with _resolved_web_session(request) as (_context, session):
if session is None:
@@ -253,8 +253,8 @@ class WebchatCallOpenView(_Public):
class WebchatCallDeclineView(_Public):
def post(self, request: Request) -> Response:
- from hub_platform.calls.errors import CallConflict, CallTokenError
- from hub_platform.calls.services import decline_call_for_identity
+ from chatballs.calls.errors import CallConflict, CallTokenError
+ from chatballs.calls.services import decline_call_for_identity
with _resolved_web_session(request) as (_context, session):
if session is None:
diff --git a/apps/backend/hub_platform/webchat/widgets.py b/apps/backend/chatballs/webchat/widgets.py
similarity index 97%
rename from apps/backend/hub_platform/webchat/widgets.py
rename to apps/backend/chatballs/webchat/widgets.py
index 6ecae62..878753d 100644
--- a/apps/backend/hub_platform/webchat/widgets.py
+++ b/apps/backend/chatballs/webchat/widgets.py
@@ -2,8 +2,8 @@ from __future__ import annotations
from django.core.exceptions import ObjectDoesNotExist, ValidationError
-from hub_platform.integrations.models import Integration, IntegrationProvider
-from hub_platform.webchat.models import (
+from chatballs.integrations.models import Integration, IntegrationProvider
+from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
diff --git a/apps/backend/hub_platform/support_portals/tests/__init__.py b/apps/backend/chatballs_backend/__init__.py
similarity index 100%
rename from apps/backend/hub_platform/support_portals/tests/__init__.py
rename to apps/backend/chatballs_backend/__init__.py
diff --git a/apps/backend/chatballs_backend/asgi.py b/apps/backend/chatballs_backend/asgi.py
new file mode 100644
index 0000000..a8a27cd
--- /dev/null
+++ b/apps/backend/chatballs_backend/asgi.py
@@ -0,0 +1,3 @@
+"""Backward-compatible app ASGI entrypoint."""
+
+from chatballs_backend.asgi_app import application # noqa: F401
diff --git a/apps/backend/hub_backend/asgi_admin.py b/apps/backend/chatballs_backend/asgi_admin.py
similarity index 54%
rename from apps/backend/hub_backend/asgi_admin.py
rename to apps/backend/chatballs_backend/asgi_admin.py
index f820f66..07fec79 100644
--- a/apps/backend/hub_backend/asgi_admin.py
+++ b/apps/backend/chatballs_backend/asgi_admin.py
@@ -2,6 +2,6 @@ import os
from django.core.asgi import get_asgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_admin")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings_admin")
application = get_asgi_application()
diff --git a/apps/backend/hub_backend/asgi_app.py b/apps/backend/chatballs_backend/asgi_app.py
similarity index 70%
rename from apps/backend/hub_backend/asgi_app.py
rename to apps/backend/chatballs_backend/asgi_app.py
index 1035a43..b26f695 100644
--- a/apps/backend/hub_backend/asgi_app.py
+++ b/apps/backend/chatballs_backend/asgi_app.py
@@ -2,14 +2,14 @@ import os
from django.core.asgi import get_asgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_app")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings_app")
# Django initializes before consumers import models.
django_asgi_app = get_asgi_application()
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
-from hub_platform.calls.routing import websocket_urlpatterns # noqa: E402
+from chatballs.calls.routing import websocket_urlpatterns # noqa: E402
application = ProtocolTypeRouter(
{
diff --git a/apps/backend/hub_backend/asgi_platform.py b/apps/backend/chatballs_backend/asgi_platform.py
similarity index 53%
rename from apps/backend/hub_backend/asgi_platform.py
rename to apps/backend/chatballs_backend/asgi_platform.py
index db5ec34..e0b1e9d 100644
--- a/apps/backend/hub_backend/asgi_platform.py
+++ b/apps/backend/chatballs_backend/asgi_platform.py
@@ -2,6 +2,6 @@ import os
from django.core.asgi import get_asgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_platform")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings_platform")
application = get_asgi_application()
diff --git a/apps/backend/hub_backend/settings.py b/apps/backend/chatballs_backend/settings.py
similarity index 69%
rename from apps/backend/hub_backend/settings.py
rename to apps/backend/chatballs_backend/settings.py
index 9fc3665..3a2207c 100644
--- a/apps/backend/hub_backend/settings.py
+++ b/apps/backend/chatballs_backend/settings.py
@@ -3,4 +3,4 @@
Runtime services use an explicit surface settings module.
"""
-from hub_backend.settings_app import * # noqa: F403
+from chatballs_backend.settings_app import * # noqa: F403
diff --git a/apps/backend/chatballs_backend/settings_admin.py b/apps/backend/chatballs_backend/settings_admin.py
new file mode 100644
index 0000000..e311d5c
--- /dev/null
+++ b/apps/backend/chatballs_backend/settings_admin.py
@@ -0,0 +1,30 @@
+# ruff: noqa: F403,F405
+import os
+
+from chatballs_backend.settings_base import *
+
+CHATBALLS_RUNTIME_SURFACE = "admin"
+ROOT_URLCONF = "chatballs_backend.urls_admin"
+ASGI_APPLICATION = "chatballs_backend.asgi_admin.application"
+WSGI_APPLICATION = "chatballs_backend.wsgi_admin.application"
+
+# ADR-HUB-0031: admin доступен только через loopback bind и SSH tunnel.
+ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
+CSRF_TRUSTED_ORIGINS = []
+CORS_ALLOWED_ORIGINS = []
+SESSION_COOKIE_SECURE = env_bool("CHATBALLS_ADMIN_COOKIE_SECURE", False)
+CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE
+SESSION_COOKIE_NAME = os.environ.get("CHATBALLS_ADMIN_SESSION_COOKIE_NAME", "chatballs_admin_session")
+CSRF_COOKIE_NAME = os.environ.get("CHATBALLS_ADMIN_CSRF_COOKIE_NAME", "chatballs_admin_csrftoken")
+SESSION_COOKIE_DOMAIN = None
+CSRF_COOKIE_DOMAIN = None
+SESSION_COOKIE_PATH = "/"
+CSRF_COOKIE_PATH = "/"
+SECURE_SSL_REDIRECT = env_bool("CHATBALLS_ADMIN_SSL_REDIRECT", False)
+
+CHATBALLS_CONTENT_SECURITY_POLICY = os.environ.get(
+ "CHATBALLS_ADMIN_CSP",
+ "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; "
+ "form-action 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
+ "script-src 'self' 'unsafe-inline'",
+)
diff --git a/apps/backend/chatballs_backend/settings_app.py b/apps/backend/chatballs_backend/settings_app.py
new file mode 100644
index 0000000..676474a
--- /dev/null
+++ b/apps/backend/chatballs_backend/settings_app.py
@@ -0,0 +1,54 @@
+# ruff: noqa: F403,F405
+import os
+
+from django.core.exceptions import ImproperlyConfigured
+
+from chatballs_backend.settings_base import *
+
+CHATBALLS_RUNTIME_SURFACE = "app"
+ROOT_URLCONF = "chatballs_backend.urls_app"
+ASGI_APPLICATION = "chatballs_backend.asgi_app.application"
+WSGI_APPLICATION = "chatballs_backend.wsgi_app.application"
+
+_app_hosts = os.environ.get("CHATBALLS_APP_ALLOWED_HOSTS", "")
+if not DEBUG and not TESTING and not _app_hosts:
+ raise ImproperlyConfigured("CHATBALLS_APP_ALLOWED_HOSTS is required for the app surface")
+CHATBALLS_APP_PRIMARY_HOSTS = env_list(
+ "CHATBALLS_APP_ALLOWED_HOSTS",
+ env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]),
+)
+_help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}"
+if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS:
+ CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern)
+if TESTING and "testserver" not in CHATBALLS_APP_PRIMARY_HOSTS:
+ CHATBALLS_APP_PRIMARY_HOSTS.append("testserver")
+# Custom portal domains are checked against the published ingress directory by
+# SupportPortalHostBoundaryMiddleware. Django's static list cannot express them.
+ALLOWED_HOSTS = ["*"]
+MIDDLEWARE.insert(
+ 1,
+ "chatballs.support_portals.host_boundary.SupportPortalHostBoundaryMiddleware",
+)
+CSRF_TRUSTED_ORIGINS = env_list(
+ "CHATBALLS_APP_CSRF_TRUSTED_ORIGINS",
+ env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", []),
+)
+
+SESSION_COOKIE_NAME = os.environ.get(
+ "CHATBALLS_APP_SESSION_COOKIE_NAME",
+ "__Host-chatballs-app-session" if SESSION_COOKIE_SECURE else "chatballs_app_session",
+)
+CSRF_COOKIE_NAME = os.environ.get(
+ "CHATBALLS_APP_CSRF_COOKIE_NAME",
+ "__Host-chatballs-app-csrf" if CSRF_COOKIE_SECURE else "chatballs_app_csrftoken",
+)
+SESSION_COOKIE_DOMAIN = None
+CSRF_COOKIE_DOMAIN = None
+SESSION_COOKIE_PATH = "/"
+CSRF_COOKIE_PATH = "/"
+
+CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_APP_PUBLIC_BASE_URL", CHATBALLS_PUBLIC_BASE_URL)
+CHATBALLS_CONTENT_SECURITY_POLICY = os.environ.get(
+ "CHATBALLS_APP_CSP",
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
+)
diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py
similarity index 64%
rename from apps/backend/hub_backend/settings_base.py
rename to apps/backend/chatballs_backend/settings_base.py
index 524462b..20e702c 100644
--- a/apps/backend/hub_backend/settings_base.py
+++ b/apps/backend/chatballs_backend/settings_base.py
@@ -5,9 +5,9 @@ from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
-from hub_backend.settings_database import build_databases
-from hub_backend.settings_env import env_bool, env_list
-from hub_backend.settings_storage import build_storage_settings
+from chatballs_backend.settings_database import build_databases
+from chatballs_backend.settings_env import env_bool, env_list
+from chatballs_backend.settings_storage import build_storage_settings
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -16,25 +16,25 @@ INSECURE_SECRET_KEY = "local-development-only"
# Автоопределение тестового прогона, чтобы manage.py test / pytest работали
# без ручного выставления production-окружения.
TESTING = "test" in sys.argv or "pytest" in sys.modules
-SECRET_KEY = os.environ.get("CUS_SECRET_KEY", INSECURE_SECRET_KEY)
-DEBUG = env_bool("CUS_DEBUG")
-_delivery_mode = os.environ.get("CUS_DELIVERY_MODE", "").strip().upper()
+SECRET_KEY = os.environ.get("CHATBALLS_SECRET_KEY", INSECURE_SECRET_KEY)
+DEBUG = env_bool("CHATBALLS_DEBUG")
+_delivery_mode = os.environ.get("CHATBALLS_DELIVERY_MODE", "").strip().upper()
if not _delivery_mode and (DEBUG or TESTING):
_delivery_mode = "CLOUD"
if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
raise ImproperlyConfigured(
- "CUS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
+ "CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
)
-CUS_DELIVERY_MODE = _delivery_mode
-ALLOWED_HOSTS = env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
+CHATBALLS_DELIVERY_MODE = _delivery_mode
+ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
ALLOWED_HOSTS.extend(["testserver", ".help.custocrm.ru", ".localhost"])
-CSRF_TRUSTED_ORIGINS = env_list("CUS_CSRF_TRUSTED_ORIGINS", [])
+CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", [])
# Запрещаем запуск в production с дефолтным/пустым ключом подписи.
if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}:
raise ImproperlyConfigured(
- "CUS_SECRET_KEY must be set to a strong value when CUS_DEBUG is disabled"
+ "CHATBALLS_SECRET_KEY must be set to a strong value when CHATBALLS_DEBUG is disabled"
)
INSTALLED_APPS = [
@@ -45,39 +45,39 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
- "hub_platform.identity",
- "hub_platform.tenancy",
- "hub_platform.platform",
- "hub_platform.products",
- "hub_platform.ai",
- "hub_platform.integrations",
- "hub_platform.channels",
- "hub_platform.conversations",
- "hub_platform.notifications",
- "hub_platform.webchat",
- "hub_platform.health",
- "hub_platform.events",
- "hub_platform.support",
- "hub_platform.support_portals",
- "hub_platform.calls",
+ "chatballs.identity",
+ "chatballs.tenancy",
+ "chatballs.platform",
+ "chatballs.products",
+ "chatballs.ai",
+ "chatballs.integrations",
+ "chatballs.channels",
+ "chatballs.conversations",
+ "chatballs.notifications",
+ "chatballs.webchat",
+ "chatballs.health",
+ "chatballs.events",
+ "chatballs.support",
+ "chatballs.support_portals",
+ "chatballs.calls",
# django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels»
- # конфликтует с доменным hub_platform.channels, а без runserver-оверрайда
+ # конфликтует с доменным chatballs.channels, а без runserver-оверрайда
# (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS.
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
- "hub_platform.http.middleware.ContentSecurityPolicyMiddleware",
- "hub_platform.http.middleware.LocalCorsMiddleware",
+ "chatballs.http.middleware.ContentSecurityPolicyMiddleware",
+ "chatballs.http.middleware.LocalCorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
- "hub_platform.events.middleware.CorrelationIdMiddleware",
- "hub_platform.tenancy.middleware.TenantContextMiddleware",
+ "chatballs.events.middleware.CorrelationIdMiddleware",
+ "chatballs.tenancy.middleware.TenantContextMiddleware",
]
TEMPLATES = [
@@ -121,7 +121,7 @@ CHANNEL_LAYERS = {
}
}
-DATABASE_ROUTERS = ["hub_platform.tenancy.routing.ForcedAliasRouter"]
+DATABASE_ROUTERS = ["chatballs.tenancy.routing.ForcedAliasRouter"]
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
@@ -131,7 +131,7 @@ AUTH_PASSWORD_VALIDATORS = [
},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
- {"NAME": "hub_platform.identity.password_validation.PasswordComplexityValidator"},
+ {"NAME": "chatballs.identity.password_validation.PasswordComplexityValidator"},
]
LANGUAGE_CODE = "ru-ru"
@@ -152,23 +152,23 @@ DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs 0
SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0
@@ -204,74 +204,74 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
# Файловые вложения знаний (ADR-HUB-0023). Файлы отдаются только через
# download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет.
MEDIA_URL = "media/"
-CUS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
+CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
base_dir=BASE_DIR,
debug=DEBUG,
testing=TESTING,
)
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
-CUS_PUBLIC_BASE_URL = os.environ.get("CUS_PUBLIC_BASE_URL", "http://localhost:8000")
+CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
# Публичные порталы поддержки размещаются на отдельных хостах.
-CUS_HELP_BASE_DOMAIN = os.environ.get(
- "CUS_HELP_BASE_DOMAIN",
+CHATBALLS_HELP_BASE_DOMAIN = os.environ.get(
+ "CHATBALLS_HELP_BASE_DOMAIN",
"help.custocrm.ru",
).strip().lower().rstrip(".")
-CUS_HELP_PUBLIC_SCHEME = os.environ.get("CUS_HELP_PUBLIC_SCHEME", "https").strip().lower()
-CUS_HELP_PUBLIC_PORT = os.environ.get("CUS_HELP_PUBLIC_PORT", "").strip()
+CHATBALLS_HELP_PUBLIC_SCHEME = os.environ.get("CHATBALLS_HELP_PUBLIC_SCHEME", "https").strip().lower()
+CHATBALLS_HELP_PUBLIC_PORT = os.environ.get("CHATBALLS_HELP_PUBLIC_PORT", "").strip()
_default_help_public_ipv4 = os.environ.get(
- "CUSTOCRM_WEB_LISTENING_IP",
+ "CHATBALLS_WEB_LISTENING_IP",
"",
).strip()
if _default_help_public_ipv4 in {"", "0.0.0.0", "::"}:
- _default_help_public_ipv4 = "127.0.0.1" if CUS_HELP_BASE_DOMAIN == "localhost" else ""
-CUS_HELP_PUBLIC_IPV4 = os.environ.get(
- "CUS_HELP_PUBLIC_IPV4",
+ _default_help_public_ipv4 = "127.0.0.1" if CHATBALLS_HELP_BASE_DOMAIN == "localhost" else ""
+CHATBALLS_HELP_PUBLIC_IPV4 = os.environ.get(
+ "CHATBALLS_HELP_PUBLIC_IPV4",
_default_help_public_ipv4,
).strip()
-if not DEBUG and not TESTING and not CUS_HELP_PUBLIC_IPV4:
+if not DEBUG and not TESTING and not CHATBALLS_HELP_PUBLIC_IPV4:
raise ImproperlyConfigured(
- "CUS_HELP_PUBLIC_IPV4 or a non-wildcard CUSTOCRM_WEB_LISTENING_IP is required"
+ "CHATBALLS_HELP_PUBLIC_IPV4 or a non-wildcard CHATBALLS_WEB_LISTENING_IP is required"
)
-if CUS_HELP_PUBLIC_IPV4:
+if CHATBALLS_HELP_PUBLIC_IPV4:
try:
- IPv4Address(CUS_HELP_PUBLIC_IPV4)
+ IPv4Address(CHATBALLS_HELP_PUBLIC_IPV4)
except ValueError as error:
raise ImproperlyConfigured(
- "CUS_HELP_PUBLIC_IPV4 must be a valid IPv4 address"
+ "CHATBALLS_HELP_PUBLIC_IPV4 must be a valid IPv4 address"
) from error
# P2P calls: opaque invitation lifetime and short-lived signaling/media access.
-CUS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CUS_CALL_INVITE_TTL_SECONDS", str(5 * 60)))
-CUS_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("CUS_CALL_ACCESS_TTL_SECONDS", str(60 * 60)))
+CHATBALLS_CALL_INVITE_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_INVITE_TTL_SECONDS", str(5 * 60)))
+CHATBALLS_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_ACCESS_TTL_SECONDS", str(60 * 60)))
# Grace period: принятый звонок без установленного соединения закрывается FAILED.
-CUS_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("CUS_CALL_CONNECT_GRACE_SECONDS", str(2 * 60)))
+CHATBALLS_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("CHATBALLS_CALL_CONNECT_GRACE_SECONDS", str(2 * 60)))
# Grace period восстановления активного звонка после обрыва участника.
-CUS_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("CUS_CALL_RECONNECT_GRACE_SECONDS", str(60)))
+CHATBALLS_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("CHATBALLS_CALL_RECONNECT_GRACE_SECONDS", str(60)))
# C07 concurrent quota: lease TTL for a p2p-call slot reservation. A crashed
# session is released by the reservation sweep once the lease lapses.
-CUS_CONCURRENT_CALL_LEASE_SECONDS = int(
- os.environ.get("CUS_CONCURRENT_CALL_LEASE_SECONDS", str(2 * 60 * 60))
+CHATBALLS_CONCURRENT_CALL_LEASE_SECONDS = int(
+ os.environ.get("CHATBALLS_CONCURRENT_CALL_LEASE_SECONDS", str(2 * 60 * 60))
)
if (
- CUS_CALL_INVITE_TTL_SECONDS <= 0
- or CUS_CALL_ACCESS_TTL_SECONDS <= 0
- or CUS_CALL_CONNECT_GRACE_SECONDS <= 0
- or CUS_CALL_RECONNECT_GRACE_SECONDS <= 0
+ CHATBALLS_CALL_INVITE_TTL_SECONDS <= 0
+ or CHATBALLS_CALL_ACCESS_TTL_SECONDS <= 0
+ or CHATBALLS_CALL_CONNECT_GRACE_SECONDS <= 0
+ or CHATBALLS_CALL_RECONNECT_GRACE_SECONDS <= 0
):
raise ImproperlyConfigured("HUB call token TTL values must be positive")
# ICE-серверы для WebRTC (SPEC-HUB-0013 §10): direct-first через STUN, TURN как
# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp).
-CUS_CALL_STUN_URLS = env_list("CUS_CALL_STUN_URLS", [])
+CHATBALLS_CALL_STUN_URLS = env_list("CHATBALLS_CALL_STUN_URLS", [])
# TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials
# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE.
-CUS_CALL_TURN_URLS = env_list("CUS_CALL_TURN_URLS", [])
-CUS_CALL_TURN_SECRET = os.environ.get("CUS_CALL_TURN_SECRET", "")
-CUS_CALL_TURN_TTL_SECONDS = int(os.environ.get("CUS_CALL_TURN_TTL_SECONDS", str(60 * 60)))
-if CUS_CALL_TURN_TTL_SECONDS <= 0:
- raise ImproperlyConfigured("CUS_CALL_TURN_TTL_SECONDS must be positive")
+CHATBALLS_CALL_TURN_URLS = env_list("CHATBALLS_CALL_TURN_URLS", [])
+CHATBALLS_CALL_TURN_SECRET = os.environ.get("CHATBALLS_CALL_TURN_SECRET", "")
+CHATBALLS_CALL_TURN_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_TURN_TTL_SECONDS", str(60 * 60)))
+if CHATBALLS_CALL_TURN_TTL_SECONDS <= 0:
+ raise ImproperlyConfigured("CHATBALLS_CALL_TURN_TTL_SECONDS must be positive")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены.
@@ -292,18 +292,18 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"],
"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
- "EXCEPTION_HANDLER": "hub_platform.api.exceptions.api_exception_handler",
+ "EXCEPTION_HANDLER": "chatballs.api.exceptions.api_exception_handler",
"DEFAULT_THROTTLE_RATES": _THROTTLE_RATES,
}
CORS_ALLOWED_ORIGINS = env_list(
- "CUS_CORS_ALLOWED_ORIGINS",
+ "CHATBALLS_CORS_ALLOWED_ORIGINS",
["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"],
)
# Конкретное значение задаёт surface settings. Middleware не добавляет header,
# если policy пуста (например, в узком техническом тесте).
-CUS_CONTENT_SECURITY_POLICY = ""
+CHATBALLS_CONTENT_SECURITY_POLICY = ""
LOGGING = {
"version": 1,
@@ -317,7 +317,7 @@ LOGGING = {
}
},
"filters": {
- "correlation_id": {"()": "hub_platform.events.logging.CorrelationIdLogFilter"}
+ "correlation_id": {"()": "chatballs.events.logging.CorrelationIdLogFilter"}
},
"handlers": {
"console": {
diff --git a/apps/backend/chatballs_backend/settings_database.py b/apps/backend/chatballs_backend/settings_database.py
new file mode 100644
index 0000000..9bf5bb0
--- /dev/null
+++ b/apps/backend/chatballs_backend/settings_database.py
@@ -0,0 +1,86 @@
+import os
+
+from django.core.exceptions import ImproperlyConfigured
+
+
+def _credentials() -> tuple[dict[str, str], dict[str, str]]:
+ users = {
+ "app": os.environ.get(
+ "POSTGRES_APP_USER", os.environ.get("POSTGRES_USER", "chatballs")
+ ),
+ "platform": os.environ.get(
+ "POSTGRES_PLATFORM_USER", os.environ.get("POSTGRES_USER", "chatballs")
+ ),
+ "migration": os.environ.get(
+ "POSTGRES_MIGRATION_USER", os.environ.get("POSTGRES_USER", "chatballs")
+ ),
+ }
+ passwords = {
+ "app": os.environ.get(
+ "POSTGRES_APP_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")
+ ),
+ "platform": os.environ.get(
+ "POSTGRES_PLATFORM_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")
+ ),
+ "migration": os.environ.get(
+ "POSTGRES_MIGRATION_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")
+ ),
+ }
+ return users, passwords
+
+
+def _pool_options(*, testing: bool) -> dict | None:
+ # ASGI-серверы исполняют ORM в короткоживущих потоках sync_to_async;
+ # persistent-соединения (CONN_MAX_AGE > 0) в таких потоках осиротевают и
+ # исчерпывают max_connections Postgres. Вместо них — psycopg pool на процесс:
+ # соединения возвращаются в пул независимо от потока и ограничены сверху.
+ # CHATBALLS_DB_POOL_MAX=0 отключает пул (короткоживущие соединения на запрос).
+ if testing:
+ return None
+ max_size = int(os.environ.get("CHATBALLS_DB_POOL_MAX", "4"))
+ if max_size <= 0:
+ return None
+ return {
+ "min_size": int(os.environ.get("CHATBALLS_DB_POOL_MIN", "1")),
+ "max_size": max_size,
+ "timeout": float(os.environ.get("CHATBALLS_DB_POOL_TIMEOUT", "10")),
+ }
+
+
+def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
+ role = os.environ.get("CHATBALLS_DB_ROLE", "app").lower()
+ if role not in {"app", "platform", "migration"}:
+ raise ImproperlyConfigured("CHATBALLS_DB_ROLE must be app, platform or migration")
+ users, passwords = _credentials()
+ if not debug and not testing and len(set(users.values())) != 3:
+ raise ImproperlyConfigured(
+ "App, platform and migration database users must be distinct"
+ )
+ pool = _pool_options(testing=testing)
+
+ def config(selected_role: str) -> dict:
+ return {
+ "ENGINE": "django.db.backends.postgresql",
+ "NAME": os.environ.get("POSTGRES_DB", "chatballs"),
+ "USER": users[selected_role],
+ "PASSWORD": passwords[selected_role],
+ "HOST": os.environ.get("POSTGRES_HOST", "postgres"),
+ "PORT": os.environ.get("POSTGRES_PORT", "5432"),
+ # Пул несовместим с persistent-соединениями: с ним CONN_MAX_AGE
+ # обязан быть 0, а без пула persistent-режим возвращать нельзя
+ # (см. _pool_options).
+ "CONN_MAX_AGE": 0,
+ "OPTIONS": {"pool": dict(pool)} if pool else {},
+ }
+
+ databases = {
+ "default": config("migration" if testing else role),
+ "platform": config("platform"),
+ }
+ if testing:
+ databases["default"]["USER"] = os.environ.get("POSTGRES_USER", "chatballs")
+ databases["default"]["PASSWORD"] = os.environ.get(
+ "POSTGRES_PASSWORD", "chatballs"
+ )
+ databases["platform"]["TEST"] = {"MIRROR": "default"}
+ return databases
diff --git a/apps/backend/hub_backend/settings_env.py b/apps/backend/chatballs_backend/settings_env.py
similarity index 100%
rename from apps/backend/hub_backend/settings_env.py
rename to apps/backend/chatballs_backend/settings_env.py
diff --git a/apps/backend/hub_backend/settings_platform.py b/apps/backend/chatballs_backend/settings_platform.py
similarity index 58%
rename from apps/backend/hub_backend/settings_platform.py
rename to apps/backend/chatballs_backend/settings_platform.py
index bb5f3bb..ca59075 100644
--- a/apps/backend/hub_backend/settings_platform.py
+++ b/apps/backend/chatballs_backend/settings_platform.py
@@ -3,25 +3,25 @@ import os
from django.core.exceptions import ImproperlyConfigured
-from hub_backend.settings_base import *
+from chatballs_backend.settings_base import *
-CUS_RUNTIME_SURFACE = "platform"
-ROOT_URLCONF = "hub_backend.urls_platform"
-ASGI_APPLICATION = "hub_backend.asgi_platform.application"
-WSGI_APPLICATION = "hub_backend.wsgi_platform.application"
+CHATBALLS_RUNTIME_SURFACE = "platform"
+ROOT_URLCONF = "chatballs_backend.urls_platform"
+ASGI_APPLICATION = "chatballs_backend.asgi_platform.application"
+WSGI_APPLICATION = "chatballs_backend.wsgi_platform.application"
-_platform_hosts = os.environ.get("CUSTOCRM_PLATFORM_ALLOWED_HOSTS", "")
+_platform_hosts = os.environ.get("CHATBALLS_PLATFORM_ALLOWED_HOSTS", "")
if not DEBUG and not TESTING and not _platform_hosts:
raise ImproperlyConfigured(
- "CUSTOCRM_PLATFORM_ALLOWED_HOSTS is required for the platform surface"
+ "CHATBALLS_PLATFORM_ALLOWED_HOSTS is required for the platform surface"
)
ALLOWED_HOSTS = env_list(
- "CUSTOCRM_PLATFORM_ALLOWED_HOSTS",
+ "CHATBALLS_PLATFORM_ALLOWED_HOSTS",
["localhost", "127.0.0.1", "platform.localhost"],
)
if "backend-platform" not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append("backend-platform")
-CSRF_TRUSTED_ORIGINS = env_list("CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS", [])
+CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS", [])
CORS_ALLOWED_ORIGINS = []
# Caddy спрашивает разрешение на on-demand сертификат Help Center по plain HTTP
@@ -31,23 +31,23 @@ CORS_ALLOWED_ORIGINS = []
SECURE_REDIRECT_EXEMPT = [r"^api/v1/gateway/"]
SESSION_COOKIE_NAME = os.environ.get(
- "CUSTOCRM_PLATFORM_SESSION_COOKIE_NAME",
- "__Host-custocrm-platform-session"
+ "CHATBALLS_PLATFORM_SESSION_COOKIE_NAME",
+ "__Host-chatballs-platform-session"
if SESSION_COOKIE_SECURE
- else "custocrm_platform_session",
+ else "chatballs_platform_session",
)
CSRF_COOKIE_NAME = os.environ.get(
- "CUSTOCRM_PLATFORM_CSRF_COOKIE_NAME",
- "__Host-custocrm-platform-csrf"
+ "CHATBALLS_PLATFORM_CSRF_COOKIE_NAME",
+ "__Host-chatballs-platform-csrf"
if CSRF_COOKIE_SECURE
- else "custocrm_platform_csrftoken",
+ else "chatballs_platform_csrftoken",
)
SESSION_COOKIE_DOMAIN = None
CSRF_COOKIE_DOMAIN = None
SESSION_COOKIE_PATH = "/"
CSRF_COOKIE_PATH = "/"
-CUS_CONTENT_SECURITY_POLICY = os.environ.get(
- "CUSTOCRM_PLATFORM_CSP",
+CHATBALLS_CONTENT_SECURITY_POLICY = os.environ.get(
+ "CHATBALLS_PLATFORM_CSP",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
)
diff --git a/apps/backend/hub_backend/settings_storage.py b/apps/backend/chatballs_backend/settings_storage.py
similarity index 58%
rename from apps/backend/hub_backend/settings_storage.py
rename to apps/backend/chatballs_backend/settings_storage.py
index d6f41db..8561dfa 100644
--- a/apps/backend/hub_backend/settings_storage.py
+++ b/apps/backend/chatballs_backend/settings_storage.py
@@ -11,50 +11,50 @@ def build_storage_settings(
testing: bool,
) -> tuple[str, Path, dict[str, dict]]:
backend = os.environ.get(
- "CUS_STORAGE_BACKEND",
+ "CHATBALLS_STORAGE_BACKEND",
"filesystem" if debug or testing else "s3",
).lower()
if backend not in {"filesystem", "s3"}:
- raise ImproperlyConfigured("CUS_STORAGE_BACKEND must be 's3' or 'filesystem'")
+ raise ImproperlyConfigured("CHATBALLS_STORAGE_BACKEND must be 's3' or 'filesystem'")
if not debug and not testing and backend != "s3":
raise ImproperlyConfigured("Production tenant storage must use the S3 backend")
default_storage: dict = {
- "BACKEND": "hub_platform.tenancy.storage_backends.TenantFileSystemStorage",
+ "BACKEND": "chatballs.tenancy.storage_backends.TenantFileSystemStorage",
}
# Файлы пользователя (фото профиля) — вне тенантного ограждения: пользователь
# может состоять в нескольких организациях. Ключи под users//.
users_storage: dict = {
- "BACKEND": "hub_platform.tenancy.storage_backends.UserFileSystemStorage",
+ "BACKEND": "chatballs.tenancy.storage_backends.UserFileSystemStorage",
}
if backend == "s3":
- bucket_name = os.environ.get("CUS_S3_BUCKET", "")
+ bucket_name = os.environ.get("CHATBALLS_S3_BUCKET", "")
if not bucket_name:
- raise ImproperlyConfigured("CUS_S3_BUCKET is required for S3 storage")
+ raise ImproperlyConfigured("CHATBALLS_S3_BUCKET is required for S3 storage")
s3_options = {
"bucket_name": bucket_name,
- "endpoint_url": os.environ.get("CUS_S3_ENDPOINT_URL") or None,
- "region_name": os.environ.get("CUS_S3_REGION") or None,
- "access_key": os.environ.get("CUS_S3_ACCESS_KEY") or None,
- "secret_key": os.environ.get("CUS_S3_SECRET_KEY") or None,
- "addressing_style": os.environ.get("CUS_S3_ADDRESSING_STYLE", "path"),
+ "endpoint_url": os.environ.get("CHATBALLS_S3_ENDPOINT_URL") or None,
+ "region_name": os.environ.get("CHATBALLS_S3_REGION") or None,
+ "access_key": os.environ.get("CHATBALLS_S3_ACCESS_KEY") or None,
+ "secret_key": os.environ.get("CHATBALLS_S3_SECRET_KEY") or None,
+ "addressing_style": os.environ.get("CHATBALLS_S3_ADDRESSING_STYLE", "path"),
"default_acl": None,
"file_overwrite": False,
"querystring_auth": True,
"querystring_expire": int(
- os.environ.get("CUS_S3_URL_EXPIRY_SECONDS", "900")
+ os.environ.get("CHATBALLS_S3_URL_EXPIRY_SECONDS", "900")
),
}
default_storage = {
- "BACKEND": "hub_platform.tenancy.storage_backends.TenantS3Storage",
+ "BACKEND": "chatballs.tenancy.storage_backends.TenantS3Storage",
"OPTIONS": s3_options,
}
users_storage = {
- "BACKEND": "hub_platform.tenancy.storage_backends.UserS3Storage",
+ "BACKEND": "chatballs.tenancy.storage_backends.UserS3Storage",
"OPTIONS": s3_options,
}
- media_root = Path(os.environ.get("CUS_MEDIA_ROOT", base_dir / "media"))
+ media_root = Path(os.environ.get("CHATBALLS_MEDIA_ROOT", base_dir / "media"))
storages = {
"default": default_storage,
"users": users_storage,
diff --git a/apps/backend/hub_backend/test_surfaces.py b/apps/backend/chatballs_backend/test_surfaces.py
similarity index 80%
rename from apps/backend/hub_backend/test_surfaces.py
rename to apps/backend/chatballs_backend/test_surfaces.py
index ce49394..3b7d970 100644
--- a/apps/backend/hub_backend/test_surfaces.py
+++ b/apps/backend/chatballs_backend/test_surfaces.py
@@ -5,20 +5,20 @@ from django.test import SimpleTestCase, override_settings
class RuntimeSurfaceRouteTests(SimpleTestCase):
- @override_settings(ROOT_URLCONF="hub_backend.urls_app")
+ @override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
def test_app_exposes_tenant_api_but_not_admin(self) -> None:
self.assertEqual(self.client.get("/api/v1/health/live/").status_code, 200)
self.assertNotEqual(self.client.get("/api/v1/auth/session/").status_code, 404)
self.assertEqual(self.client.get("/admin/login/").status_code, 404)
- @override_settings(ROOT_URLCONF="hub_backend.urls_platform")
+ @override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
def test_platform_exposes_only_platform_health(self) -> None:
self.assertEqual(self.client.get("/api/v1/health/live/").status_code, 200)
self.assertEqual(self.client.get("/api/v1/auth/session/").status_code, 404)
self.assertEqual(self.client.get("/chat-widget.js").status_code, 404)
self.assertEqual(self.client.get("/admin/login/").status_code, 404)
- @override_settings(ROOT_URLCONF="hub_backend.urls_admin")
+ @override_settings(ROOT_URLCONF="chatballs_backend.urls_admin")
def test_admin_exposes_only_django_admin(self) -> None:
self.assertEqual(self.client.get("/admin/login/").status_code, 200)
self.assertEqual(self.client.get("/api/v1/health/live/").status_code, 404)
@@ -28,9 +28,9 @@ class RuntimeSurfaceRouteTests(SimpleTestCase):
class RuntimeSurfaceSettingsTests(SimpleTestCase):
def test_cookie_names_are_isolated_and_host_only(self) -> None:
surfaces = [
- import_module("hub_backend.settings_app"),
- import_module("hub_backend.settings_platform"),
- import_module("hub_backend.settings_admin"),
+ import_module("chatballs_backend.settings_app"),
+ import_module("chatballs_backend.settings_platform"),
+ import_module("chatballs_backend.settings_admin"),
]
self.assertEqual(len({surface.SESSION_COOKIE_NAME for surface in surfaces}), 3)
@@ -39,14 +39,14 @@ class RuntimeSurfaceSettingsTests(SimpleTestCase):
self.assertTrue(all(surface.CSRF_COOKIE_DOMAIN is None for surface in surfaces))
def test_admin_hosts_are_loopback_only(self) -> None:
- admin_settings = import_module("hub_backend.settings_admin")
+ admin_settings = import_module("chatballs_backend.settings_admin")
self.assertEqual(admin_settings.ALLOWED_HOSTS, ["127.0.0.1", "localhost"])
def test_gateway_ask_endpoint_is_exempt_from_ssl_redirect(self) -> None:
# Caddy ходит на этот путь по plain HTTP и редирект считает отказом:
# 301 здесь означает, что Help Center остаётся без сертификата.
- platform_settings = import_module("hub_backend.settings_platform")
+ platform_settings = import_module("chatballs_backend.settings_platform")
self.assertTrue(
any(
diff --git a/apps/backend/hub_backend/urls.py b/apps/backend/chatballs_backend/urls.py
similarity index 53%
rename from apps/backend/hub_backend/urls.py
rename to apps/backend/chatballs_backend/urls.py
index bdbd032..d79c558 100644
--- a/apps/backend/hub_backend/urls.py
+++ b/apps/backend/chatballs_backend/urls.py
@@ -1,3 +1,3 @@
"""Backward-compatible app URLConf for management commands and tests."""
-from hub_backend.urls_app import urlpatterns # noqa: F401
+from chatballs_backend.urls_app import urlpatterns # noqa: F401
diff --git a/apps/backend/hub_backend/urls_admin.py b/apps/backend/chatballs_backend/urls_admin.py
similarity index 100%
rename from apps/backend/hub_backend/urls_admin.py
rename to apps/backend/chatballs_backend/urls_admin.py
diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/chatballs_backend/urls_app.py
similarity index 52%
rename from apps/backend/hub_backend/urls_app.py
rename to apps/backend/chatballs_backend/urls_app.py
index 3e963a7..941ccbd 100644
--- a/apps/backend/hub_backend/urls_app.py
+++ b/apps/backend/chatballs_backend/urls_app.py
@@ -1,8 +1,8 @@
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
+from chatballs.identity.demo_views import DemoMediaView
+from chatballs.webchat.views import WidgetLoaderView
urlpatterns = [
path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"),
@@ -11,8 +11,8 @@ urlpatterns = [
get_schema_view(title="Chatballs API", version="0.1.0"),
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/auth/", include("chatballs.identity.auth_urls")),
+ path("api/v1/setup/", include("chatballs.identity.setup_urls")),
path(
"api/v1/demo-media/avatars/",
DemoMediaView.as_view(),
@@ -20,42 +20,42 @@ urlpatterns = [
),
path(
"api/v1/organizations//company/",
- include("hub_platform.identity.company_urls"),
+ include("chatballs.identity.company_urls"),
),
path(
"api/v1/organizations//company/",
- include("hub_platform.products.urls"),
+ include("chatballs.products.urls"),
),
path(
"api/v1/organizations//employees/",
- include("hub_platform.identity.employee_urls"),
+ include("chatballs.identity.employee_urls"),
),
- path("api/v1/organizations//ai/", include("hub_platform.ai.urls")),
+ path("api/v1/organizations//ai/", include("chatballs.ai.urls")),
path(
"api/v1/organizations//agents/",
- include("hub_platform.ai.agent_card_urls"),
+ include("chatballs.ai.agent_card_urls"),
),
path(
"api/v1/organizations//integrations/",
- include("hub_platform.integrations.urls"),
+ include("chatballs.integrations.urls"),
),
path(
"api/v1/organizations//conversations/",
- include("hub_platform.conversations.urls"),
+ include("chatballs.conversations.urls"),
),
path(
"api/v1/organizations//notifications/",
- include("hub_platform.notifications.urls"),
+ include("chatballs.notifications.urls"),
),
path(
"api/v1/organizations//support/",
- include("hub_platform.support.urls"),
+ include("chatballs.support.urls"),
),
- path("api/v1/organizations//calls/", include("hub_platform.calls.urls")),
- path("api/v1/webchat/", include("hub_platform.webchat.urls")),
- path("api/v1/health/", include("hub_platform.health.urls")),
- path("api/v1/ai/", include("hub_platform.ai.public_urls")),
- path("api/v1/support/", include("hub_platform.support.public_urls")),
- path("api/v1/help/", include("hub_platform.support_portals.public_urls")),
- path("api/v1/calls/", include("hub_platform.calls.public_urls")),
+ path("api/v1/organizations//calls/", include("chatballs.calls.urls")),
+ path("api/v1/webchat/", include("chatballs.webchat.urls")),
+ path("api/v1/health/", include("chatballs.health.urls")),
+ path("api/v1/ai/", include("chatballs.ai.public_urls")),
+ path("api/v1/support/", include("chatballs.support.public_urls")),
+ path("api/v1/help/", include("chatballs.support_portals.public_urls")),
+ path("api/v1/calls/", include("chatballs.calls.public_urls")),
]
diff --git a/apps/backend/chatballs_backend/urls_platform.py b/apps/backend/chatballs_backend/urls_platform.py
new file mode 100644
index 0000000..e2e481a
--- /dev/null
+++ b/apps/backend/chatballs_backend/urls_platform.py
@@ -0,0 +1,12 @@
+from django.urls import include, path
+from chatballs.support_portals.gateway_views import HelpDomainAuthorizationView
+
+urlpatterns = [
+ path("api/v1/health/", include("chatballs.health.urls")),
+ path(
+ "api/v1/gateway/help-domain/",
+ HelpDomainAuthorizationView.as_view(),
+ name="gateway-help-domain",
+ ),
+ path("api/v1/", include("chatballs.platform.urls")),
+]
diff --git a/apps/backend/chatballs_backend/wsgi.py b/apps/backend/chatballs_backend/wsgi.py
new file mode 100644
index 0000000..6b1e2e7
--- /dev/null
+++ b/apps/backend/chatballs_backend/wsgi.py
@@ -0,0 +1,3 @@
+"""Backward-compatible app WSGI entrypoint."""
+
+from chatballs_backend.wsgi_app import application # noqa: F401
diff --git a/apps/backend/hub_backend/wsgi_admin.py b/apps/backend/chatballs_backend/wsgi_admin.py
similarity index 54%
rename from apps/backend/hub_backend/wsgi_admin.py
rename to apps/backend/chatballs_backend/wsgi_admin.py
index 1c18a96..59c6bae 100644
--- a/apps/backend/hub_backend/wsgi_admin.py
+++ b/apps/backend/chatballs_backend/wsgi_admin.py
@@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_admin")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings_admin")
application = get_wsgi_application()
diff --git a/apps/backend/hub_backend/wsgi_app.py b/apps/backend/chatballs_backend/wsgi_app.py
similarity index 54%
rename from apps/backend/hub_backend/wsgi_app.py
rename to apps/backend/chatballs_backend/wsgi_app.py
index d8b9bfe..9102a64 100644
--- a/apps/backend/hub_backend/wsgi_app.py
+++ b/apps/backend/chatballs_backend/wsgi_app.py
@@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_app")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings_app")
application = get_wsgi_application()
diff --git a/apps/backend/hub_backend/wsgi_platform.py b/apps/backend/chatballs_backend/wsgi_platform.py
similarity index 53%
rename from apps/backend/hub_backend/wsgi_platform.py
rename to apps/backend/chatballs_backend/wsgi_platform.py
index a3fe509..6f1e859 100644
--- a/apps/backend/hub_backend/wsgi_platform.py
+++ b/apps/backend/chatballs_backend/wsgi_platform.py
@@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_platform")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings_platform")
application = get_wsgi_application()
diff --git a/apps/backend/hub_backend/asgi.py b/apps/backend/hub_backend/asgi.py
deleted file mode 100644
index c73e303..0000000
--- a/apps/backend/hub_backend/asgi.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Backward-compatible app ASGI entrypoint."""
-
-from hub_backend.asgi_app import application # noqa: F401
diff --git a/apps/backend/hub_backend/settings_admin.py b/apps/backend/hub_backend/settings_admin.py
deleted file mode 100644
index f2d3b1f..0000000
--- a/apps/backend/hub_backend/settings_admin.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# ruff: noqa: F403,F405
-import os
-
-from hub_backend.settings_base import *
-
-CUS_RUNTIME_SURFACE = "admin"
-ROOT_URLCONF = "hub_backend.urls_admin"
-ASGI_APPLICATION = "hub_backend.asgi_admin.application"
-WSGI_APPLICATION = "hub_backend.wsgi_admin.application"
-
-# ADR-HUB-0031: admin доступен только через loopback bind и SSH tunnel.
-ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
-CSRF_TRUSTED_ORIGINS = []
-CORS_ALLOWED_ORIGINS = []
-SESSION_COOKIE_SECURE = env_bool("CUSTOCRM_ADMIN_COOKIE_SECURE", False)
-CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE
-SESSION_COOKIE_NAME = os.environ.get("CUSTOCRM_ADMIN_SESSION_COOKIE_NAME", "custocrm_admin_session")
-CSRF_COOKIE_NAME = os.environ.get("CUSTOCRM_ADMIN_CSRF_COOKIE_NAME", "custocrm_admin_csrftoken")
-SESSION_COOKIE_DOMAIN = None
-CSRF_COOKIE_DOMAIN = None
-SESSION_COOKIE_PATH = "/"
-CSRF_COOKIE_PATH = "/"
-SECURE_SSL_REDIRECT = env_bool("CUSTOCRM_ADMIN_SSL_REDIRECT", False)
-
-CUS_CONTENT_SECURITY_POLICY = os.environ.get(
- "CUSTOCRM_ADMIN_CSP",
- "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; "
- "form-action 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
- "script-src 'self' 'unsafe-inline'",
-)
diff --git a/apps/backend/hub_backend/settings_app.py b/apps/backend/hub_backend/settings_app.py
deleted file mode 100644
index 43d7440..0000000
--- a/apps/backend/hub_backend/settings_app.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# ruff: noqa: F403,F405
-import os
-
-from django.core.exceptions import ImproperlyConfigured
-
-from hub_backend.settings_base import *
-
-CUS_RUNTIME_SURFACE = "app"
-ROOT_URLCONF = "hub_backend.urls_app"
-ASGI_APPLICATION = "hub_backend.asgi_app.application"
-WSGI_APPLICATION = "hub_backend.wsgi_app.application"
-
-_app_hosts = os.environ.get("CUSTOCRM_APP_ALLOWED_HOSTS", "")
-if not DEBUG and not TESTING and not _app_hosts:
- raise ImproperlyConfigured("CUSTOCRM_APP_ALLOWED_HOSTS is required for the app surface")
-CUS_APP_PRIMARY_HOSTS = env_list(
- "CUSTOCRM_APP_ALLOWED_HOSTS",
- env_list("CUS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]),
-)
-_help_host_pattern = f".{CUS_HELP_BASE_DOMAIN}"
-if _help_host_pattern not in CUS_APP_PRIMARY_HOSTS:
- CUS_APP_PRIMARY_HOSTS.append(_help_host_pattern)
-if TESTING and "testserver" not in CUS_APP_PRIMARY_HOSTS:
- CUS_APP_PRIMARY_HOSTS.append("testserver")
-# Custom portal domains are checked against the published ingress directory by
-# SupportPortalHostBoundaryMiddleware. Django's static list cannot express them.
-ALLOWED_HOSTS = ["*"]
-MIDDLEWARE.insert(
- 1,
- "hub_platform.support_portals.host_boundary.SupportPortalHostBoundaryMiddleware",
-)
-CSRF_TRUSTED_ORIGINS = env_list(
- "CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS",
- env_list("CUS_CSRF_TRUSTED_ORIGINS", []),
-)
-
-SESSION_COOKIE_NAME = os.environ.get(
- "CUSTOCRM_APP_SESSION_COOKIE_NAME",
- "__Host-custocrm-app-session" if SESSION_COOKIE_SECURE else "custocrm_app_session",
-)
-CSRF_COOKIE_NAME = os.environ.get(
- "CUSTOCRM_APP_CSRF_COOKIE_NAME",
- "__Host-custocrm-app-csrf" if CSRF_COOKIE_SECURE else "custocrm_app_csrftoken",
-)
-SESSION_COOKIE_DOMAIN = None
-CSRF_COOKIE_DOMAIN = None
-SESSION_COOKIE_PATH = "/"
-CSRF_COOKIE_PATH = "/"
-
-CUS_PUBLIC_BASE_URL = os.environ.get("CUSTOCRM_APP_PUBLIC_BASE_URL", CUS_PUBLIC_BASE_URL)
-CUS_CONTENT_SECURITY_POLICY = os.environ.get(
- "CUSTOCRM_APP_CSP",
- "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
-)
diff --git a/apps/backend/hub_backend/settings_database.py b/apps/backend/hub_backend/settings_database.py
deleted file mode 100644
index bf5a458..0000000
--- a/apps/backend/hub_backend/settings_database.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import os
-
-from django.core.exceptions import ImproperlyConfigured
-
-
-def _credentials() -> tuple[dict[str, str], dict[str, str]]:
- users = {
- "app": os.environ.get(
- "POSTGRES_APP_USER", os.environ.get("POSTGRES_USER", "edevs_hub")
- ),
- "platform": os.environ.get(
- "POSTGRES_PLATFORM_USER", os.environ.get("POSTGRES_USER", "edevs_hub")
- ),
- "migration": os.environ.get(
- "POSTGRES_MIGRATION_USER", os.environ.get("POSTGRES_USER", "edevs_hub")
- ),
- }
- passwords = {
- "app": os.environ.get(
- "POSTGRES_APP_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "edevs_hub")
- ),
- "platform": os.environ.get(
- "POSTGRES_PLATFORM_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "edevs_hub")
- ),
- "migration": os.environ.get(
- "POSTGRES_MIGRATION_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "edevs_hub")
- ),
- }
- return users, passwords
-
-
-def _pool_options(*, testing: bool) -> dict | None:
- # ASGI-серверы исполняют ORM в короткоживущих потоках sync_to_async;
- # persistent-соединения (CONN_MAX_AGE > 0) в таких потоках осиротевают и
- # исчерпывают max_connections Postgres. Вместо них — psycopg pool на процесс:
- # соединения возвращаются в пул независимо от потока и ограничены сверху.
- # CUS_DB_POOL_MAX=0 отключает пул (короткоживущие соединения на запрос).
- if testing:
- return None
- max_size = int(os.environ.get("CUS_DB_POOL_MAX", "4"))
- if max_size <= 0:
- return None
- return {
- "min_size": int(os.environ.get("CUS_DB_POOL_MIN", "1")),
- "max_size": max_size,
- "timeout": float(os.environ.get("CUS_DB_POOL_TIMEOUT", "10")),
- }
-
-
-def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
- role = os.environ.get("CUS_DB_ROLE", "app").lower()
- if role not in {"app", "platform", "migration"}:
- raise ImproperlyConfigured("CUS_DB_ROLE must be app, platform or migration")
- users, passwords = _credentials()
- if not debug and not testing and len(set(users.values())) != 3:
- raise ImproperlyConfigured(
- "App, platform and migration database users must be distinct"
- )
- pool = _pool_options(testing=testing)
-
- def config(selected_role: str) -> dict:
- return {
- "ENGINE": "django.db.backends.postgresql",
- "NAME": os.environ.get("POSTGRES_DB", "edevs_hub"),
- "USER": users[selected_role],
- "PASSWORD": passwords[selected_role],
- "HOST": os.environ.get("POSTGRES_HOST", "postgres"),
- "PORT": os.environ.get("POSTGRES_PORT", "5432"),
- # Пул несовместим с persistent-соединениями: с ним CONN_MAX_AGE
- # обязан быть 0, а без пула persistent-режим возвращать нельзя
- # (см. _pool_options).
- "CONN_MAX_AGE": 0,
- "OPTIONS": {"pool": dict(pool)} if pool else {},
- }
-
- databases = {
- "default": config("migration" if testing else role),
- "platform": config("platform"),
- }
- if testing:
- databases["default"]["USER"] = os.environ.get("POSTGRES_USER", "edevs_hub")
- databases["default"]["PASSWORD"] = os.environ.get(
- "POSTGRES_PASSWORD", "edevs_hub"
- )
- databases["platform"]["TEST"] = {"MIRROR": "default"}
- return databases
diff --git a/apps/backend/hub_backend/urls_platform.py b/apps/backend/hub_backend/urls_platform.py
deleted file mode 100644
index 7a775ea..0000000
--- a/apps/backend/hub_backend/urls_platform.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from django.urls import include, path
-from hub_platform.support_portals.gateway_views import HelpDomainAuthorizationView
-
-urlpatterns = [
- path("api/v1/health/", include("hub_platform.health.urls")),
- path(
- "api/v1/gateway/help-domain/",
- HelpDomainAuthorizationView.as_view(),
- name="gateway-help-domain",
- ),
- path("api/v1/", include("hub_platform.platform.urls")),
-]
diff --git a/apps/backend/hub_backend/wsgi.py b/apps/backend/hub_backend/wsgi.py
deleted file mode 100644
index 9a603c3..0000000
--- a/apps/backend/hub_backend/wsgi.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Backward-compatible app WSGI entrypoint."""
-
-from hub_backend.wsgi_app import application # noqa: F401
diff --git a/apps/backend/hub_platform/ai/limits.py b/apps/backend/hub_platform/ai/limits.py
deleted file mode 100644
index 2a5400a..0000000
--- a/apps/backend/hub_platform/ai/limits.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from django.conf import settings
-from django.db.models import Sum
-from django.utils import timezone
-
-from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
-
-
-class LimitExceeded(Exception):
- pass
-
-
-def _day_start():
- now = timezone.localtime()
- return now.replace(hour=0, minute=0, second=0, microsecond=0)
-
-
-def daily_cost_micros(channel=None) -> int:
- queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
- if channel is not None:
- queryset = queryset.filter(channel=channel)
- return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
-
-
-def assert_within_limits(channel, agent) -> None:
- global_limit = settings.CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
- if global_limit and daily_cost_micros() >= global_limit:
- raise LimitExceeded("Global daily AI cost limit reached")
- # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
- # в micro-USD. 1 цент = 10 000 micro-USD.
- channel_limit = (agent.limits or {}).get("dailyCostUsd")
- if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
- raise LimitExceeded("Channel daily AI cost limit reached")
diff --git a/apps/backend/hub_platform/ai/models.py b/apps/backend/hub_platform/ai/models.py
deleted file mode 100644
index 0c23402..0000000
--- a/apps/backend/hub_platform/ai/models.py
+++ /dev/null
@@ -1,257 +0,0 @@
-import uuid
-
-from django.core.exceptions import ValidationError
-from django.db import models
-from pgvector.django import VectorField
-
-from hub_platform.tenancy.models import TenantRelationModel
-
-# Один основной агент на канал обработки (ADR-HUB-0019, ADR-HUB-0023).
-DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
-
-
-class AIAgentStatus(models.TextChoices):
- DRAFT = "DRAFT", "Draft"
- ACTIVE = "ACTIVE", "Active"
- DISABLED = "DISABLED", "Disabled"
- ARCHIVED = "ARCHIVED", "Archived"
-
-
-# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-HUB-0042 §3):
-# AI работает только через провайдера организации (AIAgent.provider_integration).
-
-
-# --- Знания: общая библиотека организации с иерархией категорий
-# (ADR-HUB-0023, ADR-HUB-0041 §8: областей видимости по отделам нет) ---
-
-
-class Knowledge(models.Model):
- organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="knowledge_items")
- category = models.ForeignKey(
- "ai.KnowledgeCategory",
- on_delete=models.PROTECT,
- related_name="knowledge_items",
- )
- title = models.CharField(max_length=255)
- description = models.CharField(max_length=500, blank=True)
- content = models.TextField(blank=True) # Markdown
- is_enabled = models.BooleanField(default=True)
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
- class Meta:
- ordering = ["title"]
- verbose_name_plural = "knowledge"
-
- def clean(self) -> None:
- super().clean()
- if self.category_id is not None and self.category.organization_id != self.organization_id:
- raise ValidationError({"category": "Category belongs to another organization"})
-
- def save(self, *args: object, **kwargs: object) -> None:
- self.clean()
- super().save(*args, **kwargs)
-
- def __str__(self) -> str:
- return f"knowledge:{self.organization_id}/{self.title}"
-
-
-# Django imports only models.py by convention. Re-export the related knowledge
-# models after Knowledge exists so they are registered without growing this file.
-from hub_platform.ai.knowledge_models import ( # noqa: E402, F401
- KnowledgeCategory,
-)
-
-
-def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:
- organization = instance.knowledge.organization
- return (
- f"organizations/{organization.public_id}/knowledge/"
- f"{instance.knowledge_id}/{instance.public_id}/{filename}"
- )
-
-
-class KnowledgeAttachment(TenantRelationModel):
- tenant_relation_fields = ("knowledge",)
- knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="attachments")
- # Непредсказуемый идентификатор публичной ссылки скачивания (ADR-HUB-0023):
- # агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
- public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
- file = models.FileField(upload_to=attachment_upload_path, max_length=512)
- # Оригинальное имя сохраняется и уникально в рамках знания: текст знания
- # ссылается на вложение по имени.
- original_name = models.CharField(max_length=255)
- content_type = models.CharField(max_length=128, blank=True)
- size = models.PositiveBigIntegerField(default=0)
- # Текст, извлечённый из файла (md/txt/pdf/docx) для retrieval-индексации.
- extracted_text = models.TextField(blank=True)
- created_at = models.DateTimeField(auto_now_add=True)
-
- class Meta:
- ordering = ["original_name"]
- constraints = [models.UniqueConstraint(fields=["knowledge", "original_name"], name="uniq_attachment_knowledge_name")]
-
- def __str__(self) -> str:
- return f"attachment:{self.knowledge_id}/{self.original_name}"
-
- def public_url(self) -> str:
- # Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
- # строится от публичного адреса Hub, а не от request.
- from django.conf import settings
- from django.urls import reverse
-
- path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id})
- return settings.CUS_PUBLIC_BASE_URL.rstrip("/") + path
-
-
-class KnowledgeFragment(TenantRelationModel):
- tenant_relation_fields = ("knowledge", "portal_article")
- # Чанк источника + его эмбеддинг (pgvector). ADR-HUB-0016. Источник — либо
- # знание библиотеки, либо опубликованная статья портала поддержки: обе
- # ветки индексируются одинаково, чтобы retrieval оставался одним запросом.
- # Перестраивается при каждом изменении содержимого источника.
- knowledge = models.ForeignKey(
- Knowledge,
- on_delete=models.CASCADE,
- related_name="fragments",
- null=True,
- blank=True,
- )
- portal_article = models.ForeignKey(
- "support_portals.PortalArticle",
- on_delete=models.CASCADE,
- related_name="fragments",
- null=True,
- blank=True,
- )
- chunk_index = models.PositiveIntegerField()
- content = models.TextField()
- # Размерность не фиксируется: совместимость локального и production embedding-провайдера.
- embedding = VectorField(null=True, blank=True)
- created_at = models.DateTimeField(auto_now_add=True)
-
- class Meta:
- ordering = ["knowledge_id", "portal_article_id", "chunk_index"]
- constraints = [
- models.UniqueConstraint(
- fields=["knowledge", "chunk_index"], name="uniq_fragment_knowledge_chunk"
- ),
- models.UniqueConstraint(
- fields=["portal_article", "chunk_index"], name="uniq_fragment_article_chunk"
- ),
- models.CheckConstraint(
- condition=(
- models.Q(knowledge__isnull=False, portal_article__isnull=True)
- | models.Q(knowledge__isnull=True, portal_article__isnull=False)
- ),
- name="fragment_single_source",
- ),
- ]
-
- def __str__(self) -> str:
- source = (
- f"knowledge:{self.knowledge_id}"
- if self.knowledge_id
- else f"article:{self.portal_article_id}"
- )
- return f"fragment:{source}/{self.chunk_index}"
-
- @property
- def source_title(self) -> str:
- """Заголовок источника для цитирования в системном промпте."""
- if self.knowledge_id is not None:
- return self.knowledge.title
- revision = self.portal_article.published_revision
- return revision.title if revision is not None else self.portal_article.slug
-
-
-# --- Агент канала: одна сущность, без релизов (ADR-HUB-0023) ---
-
-
-class AIAgent(TenantRelationModel):
- tenant_relation_fields = ("channel", "provider_integration")
- channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
- # BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
- # чего credential_mode и model были на агенте, а секрет — на канале: одно
- # решение в двух таблицах, и форма агента скрыто писала в канал.
- provider_integration = models.ForeignKey(
- "integrations.Integration",
- on_delete=models.PROTECT,
- related_name="agents",
- null=True,
- blank=True,
- )
- name = models.CharField(max_length=255)
- status = models.CharField(
- max_length=16,
- choices=AIAgentStatus.choices,
- default=AIAgentStatus.DRAFT,
- )
- lifecycle_version = models.PositiveIntegerField(default=0)
- model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
- model_params = models.JSONField(default=dict, blank=True)
- # Инструкции из трёх частей; системный промпт собирается в этом порядке.
- persona = models.TextField(blank=True) # кто он и что он
- tone = models.TextField(blank=True) # как он должен говорить
- instructions = models.TextField(blank=True) # правила работы
- # Выбор знаний из библиотеки организации.
- knowledge_items = models.ManyToManyField(Knowledge, blank=True, related_name="agents")
- # Статьи портала поддержки остаются в support_portals: агент ссылается на
- # них, а не на копию, поэтому правка статьи сразу меняет ответы агента.
- portal_articles = models.ManyToManyField(
- "support_portals.PortalArticle",
- blank=True,
- related_name="agents",
- )
- allowed_tools = models.JSONField(default=list, blank=True)
- # Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
- limits = models.JSONField(default=dict, blank=True)
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
- def __str__(self) -> str:
- return f"{self.channel.code}:agent"
-
- @property
- def is_active(self) -> bool:
- return self.status == AIAgentStatus.ACTIVE
-
- @is_active.setter
- def is_active(self, value: bool) -> None:
- self.status = AIAgentStatus.ACTIVE if value else AIAgentStatus.DISABLED
-
-
-# --- LLM usage accounting (tokens, cost) ---
-
-
-class LlmInvocationStatus(models.TextChoices):
- SUCCESS = "SUCCESS", "Успех"
- ERROR = "ERROR", "Ошибка"
- BLOCKED = "BLOCKED", "Заблокировано лимитом"
-
-
-class LlmInvocation(TenantRelationModel):
- tenant_relation_fields = ("channel", "product")
- # Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый.
- channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")
- product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True)
- purpose = models.CharField(max_length=64)
- operation = models.CharField(max_length=16) # chat | embedding
- model = models.CharField(max_length=128, blank=True)
- prompt_tokens = models.PositiveIntegerField(default=0)
- completion_tokens = models.PositiveIntegerField(default=0)
- total_tokens = models.PositiveIntegerField(default=0)
- cost_micros = models.PositiveBigIntegerField(default=0)
- currency = models.CharField(max_length=3, default="USD")
- latency_ms = models.PositiveIntegerField(default=0)
- status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
- error = models.TextField(blank=True)
- used_fragment_ids = models.JSONField(default=list, blank=True)
- created_at = models.DateTimeField(auto_now_add=True, db_index=True)
-
- class Meta:
- ordering = ["-created_at"]
- indexes = [models.Index(fields=["channel", "created_at"])]
-
- def __str__(self) -> str:
- return f"llm:{self.channel_id}/{self.operation}/{self.status}"
diff --git a/apps/backend/hub_platform/channels/apps.py b/apps/backend/hub_platform/channels/apps.py
deleted file mode 100644
index 247d333..0000000
--- a/apps/backend/hub_platform/channels/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-
-
-class ChannelsConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- label = "channels"
- name = "hub_platform.channels"
- verbose_name = "Processing channels (AI context)"
diff --git a/apps/backend/hub_platform/channels/policy.py b/apps/backend/hub_platform/channels/policy.py
deleted file mode 100644
index 0c79942..0000000
--- a/apps/backend/hub_platform/channels/policy.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7).
-
-Источник истины — пять булевых полей `Channel`. Пресет существует только в API
-и UI: он заполняет флаги при создании и полем модели не является.
-
-Инварианты выражают одно правило `ADR-HUB-0019`: непродуктовый канал не
-формирует коммерческих действий. Проверяются по итоговому состоянию, а не по
-переданным полям, — частичное применение запрещено.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, fields, replace
-
-from django.core.exceptions import ValidationError
-
-from hub_platform.channels.models import Channel
-
-POLICY_FIELDS = (
- "requires_authenticated_product_identity",
- "allow_anonymous_sessions",
- "allow_self_reported_contact",
- "allow_sales_attribution",
- "allow_checkout_actions",
-)
-
-# Ключ payload -> имя поля модели (SPEC §6.1).
-POLICY_API_FIELDS = {
- "requiresAuthenticatedProductIdentity": "requires_authenticated_product_identity",
- "allowAnonymousSessions": "allow_anonymous_sessions",
- "allowSelfReportedContact": "allow_self_reported_contact",
- "allowSalesAttribution": "allow_sales_attribution",
- "allowCheckoutActions": "allow_checkout_actions",
-}
-
-
-class PolicyPreset:
- SALES = "SALES"
- SUPPORT = "SUPPORT"
- CUSTOM = "CUSTOM"
-
-
-@dataclass(frozen=True, slots=True)
-class ChannelPolicy:
- requires_authenticated_product_identity: bool
- allow_anonymous_sessions: bool
- allow_self_reported_contact: bool
- allow_sales_attribution: bool
- allow_checkout_actions: bool
-
- @classmethod
- def from_channel(cls, channel: Channel) -> ChannelPolicy:
- return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS})
-
- @classmethod
- def from_preset(cls, preset: str) -> ChannelPolicy:
- return _PRESETS[preset]
-
- def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy:
- return replace(self, **changes)
-
- def as_model_fields(self) -> dict[str, bool]:
- return {field.name: getattr(self, field.name) for field in fields(self)}
-
- def as_payload(self) -> dict[str, bool]:
- return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()}
-
-
-# SPEC §3.3. SALES и SUPPORT требуют продукта — иначе нарушают P1-P3.
-_PRESETS = {
- PolicyPreset.SALES: ChannelPolicy(
- requires_authenticated_product_identity=False,
- allow_anonymous_sessions=True,
- allow_self_reported_contact=True,
- allow_sales_attribution=True,
- allow_checkout_actions=True,
- ),
- # Комбинация support-канала из SPEC-HUB-0010 §4.2.
- PolicyPreset.SUPPORT: ChannelPolicy(
- requires_authenticated_product_identity=True,
- allow_anonymous_sessions=False,
- allow_self_reported_contact=False,
- allow_sales_attribution=False,
- allow_checkout_actions=False,
- ),
-}
-
-
-@dataclass(frozen=True, slots=True)
-class PolicyViolation:
- rule: str
- field: str
- detail: str
-
- def payload(self) -> dict[str, str]:
- return {"rule": self.rule, "field": self.field, "detail": self.detail}
-
-
-def policy_violations(
- *, policy: ChannelPolicy, has_product: bool
-) -> tuple[PolicyViolation, ...]:
- """Нарушения P1-P5 для итогового состояния канала (SPEC §3.2)."""
- violations: list[PolicyViolation] = []
- if not has_product:
- if policy.allow_checkout_actions:
- violations.append(
- PolicyViolation(
- "P1",
- "allowCheckoutActions",
- "Коммерческие действия недоступны непродуктовому каналу",
- )
- )
- if policy.allow_sales_attribution:
- violations.append(
- PolicyViolation(
- "P2",
- "allowSalesAttribution",
- "Attribution недоступна непродуктовому каналу",
- )
- )
- if policy.requires_authenticated_product_identity:
- violations.append(
- PolicyViolation(
- "P3",
- "requiresAuthenticatedProductIdentity",
- "Продуктовая идентичность требует продукта",
- )
- )
- if policy.requires_authenticated_product_identity:
- if policy.allow_anonymous_sessions:
- violations.append(
- PolicyViolation(
- "P4",
- "allowAnonymousSessions",
- "Анонимные сессии несовместимы с обязательной идентичностью",
- )
- )
- if policy.allow_self_reported_contact:
- violations.append(
- PolicyViolation(
- "P5",
- "allowSelfReportedContact",
- "Самозаявленный контакт несовместим с обязательной идентичностью",
- )
- )
- return tuple(violations)
-
-
-def require_valid_policy(*, policy: ChannelPolicy, has_product: bool) -> None:
- violations = policy_violations(policy=policy, has_product=has_product)
- if violations:
- raise PolicyInvariantError(violations)
-
-
-class PolicyInvariantError(ValidationError):
- """Отклонение целиком: канал не сохраняется ни в каком виде."""
-
- def __init__(self, violations: tuple[PolicyViolation, ...]) -> None:
- self.violations = violations
- super().__init__("; ".join(violation.detail for violation in violations))
-
- def payload(self) -> dict[str, object]:
- return {
- "detail": "; ".join(violation.detail for violation in self.violations),
- "violations": [violation.payload() for violation in self.violations],
- }
diff --git a/apps/backend/hub_platform/channels/test_policy_migration.py b/apps/backend/hub_platform/channels/test_policy_migration.py
deleted file mode 100644
index 60d869a..0000000
--- a/apps/backend/hub_platform/channels/test_policy_migration.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.
-
-Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
-только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
-данных, поэтому она проверяется напрямую на реальном реестре моделей — это
-честнее, чем поднимать историческое состояние, где `identity` откатывается
-рассинхронно со схемой БД.
-"""
-
-import importlib
-
-from django.apps import apps
-from django.test import TestCase
-
-from hub_platform.channels.models import Channel
-from hub_platform.identity.models import Organization
-from hub_platform.products.models import Product
-
-migration = importlib.import_module(
- "hub_platform.channels.migrations.0005_enforce_policy_invariants"
-)
-
-
-class PolicyInvariantMigrationTests(TestCase):
- def setUp(self) -> None:
- self.organization = Organization.objects.create(name="Edevs", slug="edevs")
- self.product = Product.objects.create(
- organization=self.organization, code="foxray", name="FoxRay"
- )
-
- def _channel(self, code: str, **fields) -> Channel:
- return Channel.objects.create(
- organization=self.organization, code=code, name=code, **fields
- )
-
- def _run(self) -> None:
- migration.relax_non_product_channels(apps, None)
-
- def test_clears_commercial_flags_on_non_product_channels(self) -> None:
- # Ровно случай канала edevs: непродуктовый, но с checkout и attribution.
- violating = self._channel(
- "edevs", allow_checkout_actions=True, allow_sales_attribution=True
- )
-
- self._run()
-
- violating.refresh_from_db()
- self.assertFalse(violating.allow_checkout_actions)
- self.assertFalse(violating.allow_sales_attribution)
- # Остальные флаги непродуктового канала не трогаются.
- self.assertTrue(violating.allow_anonymous_sessions)
- self.assertTrue(violating.allow_self_reported_contact)
-
- def test_leaves_product_channels_untouched(self) -> None:
- product_channel = self._channel(
- "foxray-sales",
- product=self.product,
- allow_checkout_actions=True,
- allow_sales_attribution=True,
- )
-
- self._run()
-
- product_channel.refresh_from_db()
- self.assertTrue(product_channel.allow_checkout_actions)
- self.assertTrue(product_channel.allow_sales_attribution)
-
- def test_stops_the_rollout_on_p3_p5_violations(self) -> None:
- # Обязательная идентичность без продукта — смена смысла канала,
- # выбирать за владельца нельзя, поэтому выкат останавливается.
- self._channel("broken", requires_authenticated_product_identity=True)
- untouched = self._channel(
- "edevs", allow_checkout_actions=True, allow_sales_attribution=True
- )
-
- with self.assertRaises(RuntimeError) as error:
- self._run()
-
- self.assertIn("broken", str(error.exception))
- untouched.refresh_from_db()
- self.assertTrue(untouched.allow_checkout_actions)
-
- def test_is_idempotent(self) -> None:
- self._channel("edevs", allow_checkout_actions=True)
-
- self._run()
- self._run()
-
- self.assertEqual(
- Channel.objects.filter(
- product__isnull=True, allow_checkout_actions=True
- ).count(),
- 0,
- )
diff --git a/apps/backend/hub_platform/conversations/admin.py b/apps/backend/hub_platform/conversations/admin.py
deleted file mode 100644
index 1eafa9d..0000000
--- a/apps/backend/hub_platform/conversations/admin.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from django.contrib import admin
-
-from hub_platform.conversations.models import Contact, Conversation, Message
-
-
-@admin.register(Conversation)
-class ConversationAdmin(admin.ModelAdmin):
- list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")
- list_filter = ("lifecycle", "control_mode")
-
-
-@admin.register(Contact)
-class ContactAdmin(admin.ModelAdmin):
- list_display = ("id", "name", "organization", "created_at")
- search_fields = ("name",)
-
-
-@admin.register(Message)
-class MessageAdmin(admin.ModelAdmin):
- list_display = ("id", "conversation", "author_type", "created_at")
- list_filter = ("author_type",)
diff --git a/apps/backend/hub_platform/conversations/apps.py b/apps/backend/hub_platform/conversations/apps.py
deleted file mode 100644
index 70221b7..0000000
--- a/apps/backend/hub_platform/conversations/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-
-
-class ConversationsConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- label = "conversations"
- name = "hub_platform.conversations"
- verbose_name = "Conversations (contacts, dialogs, messages)"
diff --git a/apps/backend/hub_platform/conversations/maintenance.py b/apps/backend/hub_platform/conversations/maintenance.py
deleted file mode 100644
index 791c5d3..0000000
--- a/apps/backend/hub_platform/conversations/maintenance.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import logging
-from datetime import timedelta
-
-from django.utils import timezone
-
-from hub_platform.conversations.models import Conversation, LifecycleState
-
-logger = logging.getLogger(__name__)
-
-# ADR-HUB-0002: семь дней без активности переводят OPEN в CLOSED.
-AUTOCLOSE_DAYS = 7
-
-
-def close_stale_conversations(context, days: int = AUTOCLOSE_DAYS) -> int:
- cutoff = timezone.now() - timedelta(days=days)
- closed = Conversation.objects.filter(
- organization=context.organization,
- lifecycle=LifecycleState.OPEN,
- last_activity_at__lte=cutoff,
- ).update(
- lifecycle=LifecycleState.CLOSED
- )
- if closed:
- logger.info("Auto-closed %s stale conversations (>%sd inactive)", closed, days)
- return closed
diff --git a/apps/backend/hub_platform/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py b/apps/backend/hub_platform/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py
deleted file mode 100644
index 6b0b7eb..0000000
--- a/apps/backend/hub_platform/conversations/migrations/0012_message_audio_message_audio_content_type_and_more.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Generated by Django 5.2.15 on 2026-09-04 20:40
-
-import hub_platform.conversations.models
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('conversations', '0011_conversation_archived_at_conversation_note_and_more'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='message',
- name='audio',
- field=models.FileField(blank=True, max_length=512, upload_to=hub_platform.conversations.models.message_audio_upload_path),
- ),
- migrations.AddField(
- model_name='message',
- name='audio_content_type',
- field=models.CharField(blank=True, max_length=64),
- ),
- migrations.AddField(
- model_name='message',
- name='duration_seconds',
- field=models.PositiveIntegerField(default=0),
- ),
- migrations.AddField(
- model_name='message',
- name='transcript',
- field=models.TextField(blank=True),
- ),
- migrations.AddField(
- model_name='message',
- name='transcript_status',
- field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),
- ),
- migrations.AlterField(
- model_name='message',
- name='kind',
- field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),
- ),
- ]
diff --git a/apps/backend/hub_platform/conversations/poller.py b/apps/backend/hub_platform/conversations/poller.py
deleted file mode 100644
index 066db41..0000000
--- a/apps/backend/hub_platform/conversations/poller.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import logging
-
-from hub_platform.conversations import transports
-from hub_platform.conversations.ingest import ingest_inbound
-from hub_platform.integrations.models import Integration
-
-logger = logging.getLogger(__name__)
-
-
-def poll_all_messengers(context) -> int:
- """Poll every messenger connection bound to a channel; ingest inbound. Returns count."""
- # Сервисные боты уведомлений поллятся отдельно (notifications.binding).
- # Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки
- # без ключа purpose (NULL в SQL), т.е. все клиентские боты.
- integrations = [
- integration
- for integration in Integration.objects.filter(
- organization=context.organization,
- provider__in=transports.SUPPORTED_PROVIDERS,
- is_active=True,
- channel__isnull=False,
- channel__is_active=True,
- ).exclude(secret="")
- if integration.config.get("purpose") != "notifications"
- ]
- total = 0
- for integration in integrations:
- messages, new_marker = transports.poll(integration)
- for inbound in messages:
- try:
- ingest_inbound(integration, inbound)
- total += 1
- except Exception: # pragma: no cover
- logger.exception("Ingest failed for integration %s", integration.id)
- if new_marker and new_marker != integration.poll_marker:
- integration.poll_marker = new_marker
- integration.save(update_fields=["poll_marker", "updated_at"])
- return total
diff --git a/apps/backend/hub_platform/conversations/services.py b/apps/backend/hub_platform/conversations/services.py
deleted file mode 100644
index 8f5d9c2..0000000
--- a/apps/backend/hub_platform/conversations/services.py
+++ /dev/null
@@ -1,205 +0,0 @@
-from django.db import transaction
-from django.utils import timezone
-
-from hub_platform.conversations import transports
-from hub_platform.conversations.models import (
- ConnectionIdentity,
- Conversation,
- ControlMode,
- ExpectedResponder,
- LifecycleState,
- Message,
- MessageAuthor,
- MessageKind,
-)
-from hub_platform.identity.models import EmployeeRole
-from hub_platform.integrations.models import IntegrationProvider
-from hub_platform.tenancy.context import TenantContext
-
-CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
-CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
-
-
-class ClaimError(Exception):
- pass
-
-
-def _require_open(conversation: Conversation) -> None:
- if conversation.lifecycle != LifecycleState.OPEN:
- raise ClaimError("Диалог закрыт")
-
-
-def _operator_label(operator) -> str:
- return getattr(operator, "full_name", "") or operator.email
-
-
-@transaction.atomic
-def claim_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
- # Атомарный перехват у AI (ADR-HUB-0003): только один оператор забирает диалог.
- conversation = Conversation.objects.select_for_update().get(
- id=conversation_id, organization=context.organization
- )
- return claim_locked_conversation(context=context, conversation=conversation)
-
-
-def claim_locked_conversation(*, context: TenantContext, conversation: Conversation) -> Conversation:
- """Claim an already locked conversation inside the caller's transaction."""
- operator = context.actor_user
- if operator is None or conversation.organization_id != context.organization_id:
- raise ClaimError("Диалог недоступен")
- _require_open(conversation)
- # Владелец (OWNER) может перехватить диалог у любого оператора; прочие сотрудники
- # не могут забрать диалог, уже назначенный другому оператору.
- is_owner = context.membership is not None and context.membership.role == EmployeeRole.OWNER
- if (
- not is_owner
- and conversation.control_mode == ControlMode.HUMAN
- and conversation.assigned_operator_id
- and conversation.assigned_operator_id != operator.id
- ):
- raise ClaimError("Диалог уже ведёт другой оператор")
- conversation.control_mode = ControlMode.HUMAN
- conversation.assigned_operator = operator
- conversation.expected_responder = ExpectedResponder.OPERATOR
- conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
- Message.objects.create(
- conversation=conversation,
- author_type=MessageAuthor.SYSTEM,
- text=f"Оператор {_operator_label(operator)} перехватил диалог",
- )
- return conversation
-
-
-@transaction.atomic
-def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversation:
- conversation = Conversation.objects.select_for_update().get(
- id=conversation_id, organization=context.organization
- )
- _require_open(conversation)
- agent = getattr(conversation.channel, "ai_agent", None)
- if agent is None or not agent.is_active:
- raise ClaimError("У канала нет активного AI-агента")
- conversation.control_mode = ControlMode.AI
- conversation.assigned_operator = None
- conversation.expected_responder = ExpectedResponder.AI
- conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
- Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
- return conversation
-
-
-@transaction.atomic
-def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversation:
- # Оператор возвращает диалог в общую очередь (ADR-HUB-0003): снят с себя, ждёт оператора.
- conversation = Conversation.objects.select_for_update().get(
- id=conversation_id, organization=context.organization
- )
- _require_open(conversation)
- conversation.control_mode = ControlMode.PAUSED
- conversation.assigned_operator = None
- conversation.expected_responder = ExpectedResponder.OPERATOR
- conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
- Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
- return conversation
-
-
-def post_operator_message(
- *, context: TenantContext, conversation: Conversation, text: str
-) -> Message:
- operator = context.actor_user
- if operator is None or conversation.organization_id != context.organization_id:
- raise Conversation.DoesNotExist
- _require_open(conversation)
- message = Message.objects.create(
- conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
- )
- conversation.last_activity_at = timezone.now()
- conversation.expected_responder = ExpectedResponder.CUSTOMER
- conversation.save(update_fields=["last_activity_at", "expected_responder"])
- # Отправляем в тот же мессенджер, откуда пришёл клиент.
- if conversation.connection_id:
- identity = ConnectionIdentity.objects.filter(
- connection=conversation.connection, contact=conversation.contact
- ).first()
- transports.send_reply(
- conversation.connection,
- chat_id=conversation.external_chat_id,
- user_id=identity.external_user_id if identity else "",
- text=text,
- )
- return message
-
-
-def request_contact(*, context: TenantContext, conversation: Conversation) -> Message:
- """Запрос контакта у клиента: TG/MAX — сообщение с кнопкой «Поделиться
- контактом», Web — виджет рисует форму телефона по kind=contact_request."""
- operator = context.actor_user
- if operator is None or conversation.organization_id != context.organization_id:
- raise Conversation.DoesNotExist
- _require_open(conversation)
- is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
- text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
- message = Message.objects.create(
- conversation=conversation,
- author_type=MessageAuthor.OPERATOR,
- author_user=operator,
- kind=MessageKind.CONTACT_REQUEST,
- text=text,
- )
- conversation.last_activity_at = timezone.now()
- conversation.expected_responder = ExpectedResponder.CUSTOMER
- conversation.save(update_fields=["last_activity_at", "expected_responder"])
- if conversation.connection_id:
- identity = ConnectionIdentity.objects.filter(
- connection=conversation.connection, contact=conversation.contact
- ).first()
- transports.send_contact_request(
- conversation.connection,
- chat_id=conversation.external_chat_id,
- user_id=identity.external_user_id if identity else "",
- text=text,
- )
- return message
-
-
-@transaction.atomic
-def close_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
- conversation = Conversation.objects.select_for_update().get(
- id=conversation_id, organization=context.organization
- )
- _require_open(conversation)
- conversation.lifecycle = LifecycleState.CLOSED
- conversation.control_mode = ControlMode.PAUSED
- conversation.assigned_operator = None
- conversation.expected_responder = ExpectedResponder.NOBODY
- conversation.save(
- update_fields=[
- "lifecycle",
- "control_mode",
- "assigned_operator",
- "expected_responder",
- ]
- )
- return conversation
-
-
-@transaction.atomic
-def mark_conversation_as_spam(
- *, context: TenantContext, conversation_id: int
-) -> Conversation:
- conversation = Conversation.objects.select_for_update().get(
- id=conversation_id, organization=context.organization
- )
- _require_open(conversation)
- conversation.lifecycle = LifecycleState.SPAM
- conversation.control_mode = ControlMode.PAUSED
- conversation.assigned_operator = None
- conversation.expected_responder = ExpectedResponder.NOBODY
- conversation.save(
- update_fields=[
- "lifecycle",
- "control_mode",
- "assigned_operator",
- "expected_responder",
- ]
- )
- return conversation
diff --git a/apps/backend/hub_platform/conversations/test_email_transport.py b/apps/backend/hub_platform/conversations/test_email_transport.py
deleted file mode 100644
index c2b08af..0000000
--- a/apps/backend/hub_platform/conversations/test_email_transport.py
+++ /dev/null
@@ -1,283 +0,0 @@
-"""Email-транспорт: разбор писем, UID-курсор, тредирование (SPEC-HUB-0025 §5)."""
-
-from email import message_from_bytes, policy
-from email.message import EmailMessage as MimeMessage
-from unittest import mock
-
-from django.test import TestCase
-
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.models import Contact, Conversation, MessageAuthor
-from hub_platform.conversations.clients import client_detail, clients_overview
-from hub_platform.conversations.serializers import conversation_payload
-from hub_platform.conversations.transports import email as email_transport
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
-
-EMAIL_CONFIG = {
- "email": "support@edevs.tech",
- "imap_host": "imap.test",
- "imap_port": 993,
- "imap_ssl": True,
- "smtp_host": "smtp.test",
- "smtp_port": 465,
- "smtp_ssl": True,
-}
-
-
-def _raw(*, from_="Иван Петров ", subject="Вопрос по FoxRay", text="Здравствуйте!", message_id="", html=None, attach=False) -> bytes:
- message = MimeMessage()
- message["From"] = from_
- message["Subject"] = subject
- if message_id:
- message["Message-ID"] = message_id
- if html is not None:
- message.add_alternative(html, subtype="html")
- else:
- message.set_content(text)
- if attach:
- message.add_attachment(b"%PDF", maintype="application", subtype="pdf", filename="doc.pdf")
- return message.as_bytes()
-
-
-def _parsed(raw: bytes):
- return message_from_bytes(raw, policy=policy.default)
-
-
-def _integration(**overrides) -> Integration:
- fields = {
- "kind": IntegrationKind.MESSENGER,
- "provider": IntegrationProvider.EMAIL,
- "name": "Почта",
- "secret": "app-password",
- "config": dict(EMAIL_CONFIG),
- "poll_marker": "",
- }
- fields.update(overrides)
- return Integration(**fields)
-
-
-class EmailNormalizeTests(TestCase):
- def test_plain_message_becomes_inbound_with_thread_meta(self) -> None:
- inbound, message_id = email_transport._normalize(
- _parsed(_raw()), own_address="support@edevs.tech", fallback_id="7:100"
- )
- self.assertIsNotNone(inbound)
- self.assertEqual(inbound.external_id, "")
- self.assertEqual(inbound.user_id, "ivan@example.com")
- self.assertEqual(inbound.chat_id, "ivan@example.com")
- self.assertEqual(inbound.display_name, "Иван Петров")
- self.assertEqual(inbound.text, "Здравствуйте!")
- self.assertEqual(inbound.thread_meta, {"subject": "Вопрос по FoxRay", "last_message_id": ""})
- self.assertEqual(message_id, "")
-
- def test_own_message_is_skipped(self) -> None:
- inbound, _ = email_transport._normalize(
- _parsed(_raw(from_="support@edevs.tech")), own_address="support@edevs.tech", fallback_id="7:100"
- )
- self.assertIsNone(inbound)
-
- def test_html_only_body_is_flattened_to_text(self) -> None:
- inbound, _ = email_transport._normalize(
- _parsed(_raw(html="Добрый день!
Сколько стоит «FoxRay»?
")),
- own_address="support@edevs.tech",
- fallback_id="7:100",
- )
- self.assertIsNotNone(inbound)
- self.assertIn("Добрый день!", inbound.text)
- self.assertIn("«FoxRay»", inbound.text)
- self.assertNotIn("", inbound.text)
- self.assertIn("
Добрый день!
", inbound.content_html)
-
- def test_html_body_is_sanitized_before_ingest(self) -> None:
- inbound, _ = email_transport._normalize(
- _parsed(
- _raw(
- html=(
- 'Здравствуйте!
'
- ''
- 'опасная ссылка'
- 'сайт'
- )
- )
- ),
- own_address="support@edevs.tech",
- fallback_id="7:100",
- )
- self.assertIsNotNone(inbound)
- self.assertNotIn("onclick", inbound.content_html)
- self.assertNotIn("script", inbound.content_html)
- self.assertNotIn("alert", inbound.content_html)
- self.assertNotIn("javascript:", inbound.content_html)
- self.assertIn('href="https://example.com/path"', inbound.content_html)
-
- def test_attachments_add_note(self) -> None:
- inbound, _ = email_transport._normalize(
- _parsed(_raw(attach=True)), own_address="support@edevs.tech", fallback_id="7:100"
- )
- self.assertIn("[Вложения не поддерживаются: 1 файл(ов)]", inbound.text)
-
- def test_missing_message_id_falls_back_to_uid(self) -> None:
- inbound, _ = email_transport._normalize(
- _parsed(_raw(message_id="")), own_address="support@edevs.tech", fallback_id="7:100"
- )
- self.assertEqual(inbound.external_id, "7:100")
-
-
-class _FakeImap:
- def __init__(self, *, validity: int, next_uid: int, mailbox: dict[int, bytes]):
- self.validity = validity
- self.next_uid = next_uid
- self.mailbox = mailbox
-
- def select(self, name, readonly=False):
- return "OK", [b""]
-
- def status(self, name, item):
- value = self.validity if "UIDVALIDITY" in item else self.next_uid
- key = "UIDVALIDITY" if "UIDVALIDITY" in item else "UIDNEXT"
- return "OK", [f"INBOX ({key} {value})".encode()]
-
- def uid(self, command, *args):
- if command == "SEARCH":
- # Реальный IMAP на «N:*» всегда возвращает и старшее письмо.
- return "OK", [" ".join(str(u) for u in sorted(self.mailbox)).encode() or b""]
- return "OK", [(b"1 (RFC822 {0}", self.mailbox[int(args[0])]), b")"]
-
- def logout(self):
- return "BYE", []
-
-
-class EmailPollTests(TestCase):
- def _poll(self, integration, fake):
- with mock.patch.object(email_transport, "_imap_connect", return_value=fake):
- return email_transport.poll_updates(integration)
-
- def test_first_run_sets_cursor_without_ingesting_history(self) -> None:
- fake = _FakeImap(validity=7, next_uid=100, mailbox={98: _raw(), 99: _raw()})
- messages, marker = self._poll(_integration(poll_marker=""), fake)
- self.assertEqual(messages, [])
- self.assertEqual(marker, "7:99")
-
- def test_new_mail_after_cursor_is_ingested(self) -> None:
- fake = _FakeImap(validity=7, next_uid=101, mailbox={99: _raw(message_id=""), 100: _raw()})
- messages, marker = self._poll(_integration(poll_marker="7:99"), fake)
- self.assertEqual([m.external_id for m in messages], [""])
- self.assertEqual(marker, "7:100")
-
- def test_uidvalidity_change_resets_cursor(self) -> None:
- fake = _FakeImap(validity=8, next_uid=50, mailbox={49: _raw()})
- messages, marker = self._poll(_integration(poll_marker="7:99"), fake)
- self.assertEqual(messages, [])
- self.assertEqual(marker, "8:49")
-
- def test_connection_error_keeps_cursor(self) -> None:
- integration = _integration(poll_marker="7:99")
- with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
- messages, marker = email_transport.poll_updates(integration)
- self.assertEqual(messages, [])
- self.assertEqual(marker, "7:99")
-
-
-class EmailSendTests(TestCase):
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.organization = Organization.objects.get(slug="edevs")
- self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
- self.integration = _integration(organization=self.organization, channel=self.channel)
- self.integration.save()
- contact = Contact.objects.create(organization=self.organization, name="Иван")
- self.conversation = Conversation.objects.create(
- organization=self.organization,
- channel=self.channel,
- connection=self.integration,
- contact=contact,
- external_chat_id="ivan@example.com",
- transport_meta={"subject": "Вопрос по FoxRay", "last_message_id": ""},
- )
-
- def test_reply_goes_into_the_same_thread(self) -> None:
- with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:
- ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")
- self.assertTrue(ok)
- smtp = smtp_cls.return_value
- smtp.login.assert_called_once_with("support@edevs.tech", "app-password")
- outgoing = smtp.send_message.call_args.args[0]
- self.assertEqual(outgoing["To"], "ivan@example.com")
- self.assertEqual(outgoing["Subject"], "Re: Вопрос по FoxRay")
- self.assertEqual(outgoing["In-Reply-To"], "")
- self.assertEqual(outgoing["References"], "")
-
- def test_send_without_thread_meta_uses_fallback_subject(self) -> None:
- self.conversation.transport_meta = {}
- self.conversation.save(update_fields=["transport_meta"])
- with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:
- ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")
- self.assertTrue(ok)
- outgoing = smtp_cls.return_value.send_message.call_args.args[0]
- self.assertEqual(outgoing["Subject"], "Re: Ваше обращение")
- self.assertIsNone(outgoing["In-Reply-To"])
-
- def test_smtp_failure_returns_false(self) -> None:
- with mock.patch.object(email_transport.smtplib, "SMTP_SSL", side_effect=OSError("refused")):
- ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Привет")
- self.assertFalse(ok)
-
-
-class EmailIngestThreadMetaTests(TestCase):
- """ingest пишет transport_meta: тема — от первого письма, Message-ID — последний."""
-
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.organization = Organization.objects.get(slug="edevs")
- self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
- self.integration = _integration(organization=self.organization, channel=self.channel)
- self.integration.save()
-
- def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:
- from hub_platform.conversations.ingest import ingest_inbound
- from hub_platform.conversations.transports.base import InboundMessage
-
- inbound = InboundMessage(
- external_id=external_id,
- user_id="ivan@example.com",
- chat_id="ivan@example.com",
- text="Здравствуйте!",
- display_name="Иван",
- thread_meta={"subject": subject, "last_message_id": message_id},
- )
- with (
- mock.patch("hub_platform.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")),
- mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True),
- ):
- ingest_inbound(self.integration, inbound)
-
- def test_subject_pinned_to_first_message_id_follows_last(self) -> None:
- self._ingest(external_id="", subject="Вопрос по FoxRay", message_id="")
- self._ingest(external_id="", subject="Re: Вопрос по FoxRay", message_id="")
- conversation = Conversation.objects.get(channel=self.channel)
- self.assertEqual(conversation.transport_meta["subject"], "Вопрос по FoxRay")
- self.assertEqual(conversation.transport_meta["last_message_id"], "")
- authors = list(conversation.messages.values_list("author_type", flat=True))
- self.assertIn(MessageAuthor.CONTACT, authors)
-
- def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None:
- self._ingest(
- external_id="",
- subject="Контакты",
- message_id="",
- )
- conversation = Conversation.objects.get(channel=self.channel)
- dialog = conversation_payload(conversation, with_messages=True)
- self.assertEqual(dialog["connection"]["provider"], "EMAIL")
- self.assertEqual(dialog["contact"]["email"], "ivan@example.com")
- self.assertEqual(dialog["messages"][0]["contentHtml"], "")
-
- overview = clients_overview(self.organization.id)
- self.assertEqual(overview[0]["email"], "ivan@example.com")
- self.assertIn("EMAIL", overview[0]["channels"])
-
- detail = client_detail(self.organization.id, conversation.contact_id)
- self.assertEqual(detail["email"], "ivan@example.com")
- self.assertEqual(detail["identities"][0]["value"], "ivan@example.com")
diff --git a/apps/backend/hub_platform/conversations/test_lifecycle.py b/apps/backend/hub_platform/conversations/test_lifecycle.py
deleted file mode 100644
index 077b4c7..0000000
--- a/apps/backend/hub_platform/conversations/test_lifecycle.py
+++ /dev/null
@@ -1,190 +0,0 @@
-import json
-from unittest import mock
-
-from django.test import TestCase
-
-from hub_platform.channels.models import Channel
-from hub_platform.conversations.ingest import ingest_inbound
-from hub_platform.conversations.models import (
- ConnectionIdentity,
- Contact,
- ControlMode,
- Conversation,
- ExpectedResponder,
- LifecycleState,
- MessageAuthor,
-)
-from hub_platform.conversations.transports.base import InboundMessage
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import HumanUser, Organization
-from hub_platform.integrations.models import (
- Integration,
- IntegrationKind,
- IntegrationProvider,
-)
-from hub_platform.testing import TenantAPIClient as APIClient
-
-
-def _connection(channel: Channel) -> Integration:
- return Integration.objects.create(
- organization=channel.organization,
- kind=IntegrationKind.MESSENGER,
- provider=IntegrationProvider.TELEGRAM,
- name="test-bot",
- channel=channel,
- )
-
-
-class OperatorOnlyIngestTests(TestCase):
- def setUp(self) -> None:
- bootstrap_edevs_owner(
- email="owner@edevs.tech", password="temporary-password"
- )
- self.organization = Organization.objects.get(slug="edevs")
- self.channel = Channel.objects.create(
- organization=self.organization,
- code="operator-only",
- name="Операторский канал",
- )
- self.integration = _connection(self.channel)
-
- def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
- with (
- mock.patch(
- "hub_platform.conversations.ingest.run_channel_turn"
- ) as ai_turn,
- mock.patch(
- "hub_platform.conversations.ingest.transports.send_reply"
- ) as send,
- ):
- ingest_inbound(self.integration, inbound)
- return ai_turn, send
-
- def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
- ai_turn, send = self._ingest_without_ai(
- InboundMessage(
- external_id="operator-1",
- user_id="user-1",
- chat_id="chat-1",
- text="Нужна помощь",
- display_name="Гость",
- )
- )
-
- conversation = self.channel.conversations.get()
- self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
- self.assertEqual(
- conversation.expected_responder, ExpectedResponder.OPERATOR
- )
- self.assertEqual(
- list(conversation.messages.values_list("author_type", flat=True)),
- [MessageAuthor.CONTACT],
- )
- ai_turn.assert_not_called()
- send.assert_not_called()
-
- def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None:
- contact = Contact.objects.create(
- organization=self.organization, name="Клиент"
- )
- ConnectionIdentity.objects.create(
- contact=contact,
- connection=self.integration,
- external_user_id="user-existing",
- )
- conversation = Conversation.objects.create(
- organization=self.organization,
- channel=self.channel,
- connection=self.integration,
- contact=contact,
- external_chat_id="chat-existing",
- control_mode=ControlMode.AI,
- expected_responder=ExpectedResponder.AI,
- )
-
- ai_turn, send = self._ingest_without_ai(
- InboundMessage(
- external_id="operator-2",
- user_id="user-existing",
- chat_id="chat-existing",
- text="Вы здесь?",
- display_name="Клиент",
- )
- )
-
- conversation.refresh_from_db()
- self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
- self.assertEqual(
- conversation.expected_responder, ExpectedResponder.OPERATOR
- )
- self.assertEqual(conversation.messages.count(), 1)
- ai_turn.assert_not_called()
- send.assert_not_called()
-
-
-class ClosedConversationActionTests(TestCase):
- def setUp(self) -> None:
- bootstrap_edevs_owner(
- email="owner@edevs.tech", password="temporary-password"
- )
- organization = Organization.objects.get(slug="edevs")
- channel = Channel.objects.create(
- organization=organization,
- code="closed-actions",
- name="Закрытые диалоги",
- )
- integration = _connection(channel)
- contact = Contact.objects.create(organization=organization, name="Иван")
- owner = HumanUser.objects.get(email="owner@edevs.tech")
- self.conversation = Conversation.objects.create(
- organization=organization,
- channel=channel,
- connection=integration,
- contact=contact,
- lifecycle=LifecycleState.CLOSED,
- control_mode=ControlMode.HUMAN,
- assigned_operator=owner,
- expected_responder=ExpectedResponder.NOBODY,
- )
- self.client = APIClient()
- self.client.login(
- username="owner@edevs.tech", password="temporary-password"
- )
-
- def test_closed_dialog_rejects_operational_actions(self) -> None:
- base = f"/api/v1/conversations/{self.conversation.id}"
- self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409)
- self.assertEqual(self.client.post(f"{base}/release/").status_code, 409)
- self.assertEqual(
- self.client.post(f"{base}/return-queue/").status_code, 409
- )
- self.assertEqual(
- self.client.post(
- f"{base}/messages/",
- data=json.dumps({"text": "Поздний ответ"}),
- content_type="application/json",
- ).status_code,
- 409,
- )
- self.assertEqual(self.client.post(f"{base}/close/").status_code, 409)
- self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409)
-
- def test_open_dialog_can_be_marked_as_spam(self) -> None:
- self.conversation.lifecycle = LifecycleState.OPEN
- self.conversation.expected_responder = ExpectedResponder.OPERATOR
- self.conversation.save(
- update_fields=["lifecycle", "expected_responder"]
- )
-
- response = self.client.post(
- f"/api/v1/conversations/{self.conversation.id}/spam/"
- )
-
- self.assertEqual(response.status_code, 200)
- payload = response.json()["conversation"]
- self.assertEqual(payload["lifecycle"], LifecycleState.SPAM)
- self.assertEqual(payload["controlMode"], ControlMode.PAUSED)
- self.assertIsNone(payload["assignedOperator"])
- self.assertEqual(
- payload["expectedResponder"], ExpectedResponder.NOBODY
- )
diff --git a/apps/backend/hub_platform/conversations/transports/email.py b/apps/backend/hub_platform/conversations/transports/email.py
deleted file mode 100644
index 9a0a9fb..0000000
--- a/apps/backend/hub_platform/conversations/transports/email.py
+++ /dev/null
@@ -1,195 +0,0 @@
-"""Email (IMAP/SMTP) transport (ADR-HUB-0035, SPEC-HUB-0025 §3.3–3.4).
-
-Polling IMAP with a UID cursor in poll_marker («uidvalidity:last_uid»), replies
-via SMTP into the same thread (Re:/In-Reply-To/References from the dialog's
-transport_meta). One mailbox password (app password) serves both sides.
-"""
-
-from __future__ import annotations
-
-import html
-import imaplib
-import logging
-import re
-import smtplib
-from email import message_from_bytes, policy
-from email.message import EmailMessage
-from email.utils import parseaddr
-
-from django.conf import settings
-
-from hub_platform.conversations.html_sanitizer import sanitize_email_html
-from hub_platform.conversations.transports.base import InboundMessage
-
-logger = logging.getLogger(__name__)
-
-DEFAULT_IMAP_PORT = 993
-DEFAULT_SMTP_PORT = 465
-
-_TAG = re.compile(r"<[^>]+>")
-_BLANK_LINES = re.compile(r"\n{3,}")
-
-
-def _address(integration) -> str:
- return str(integration.config.get("email", "")).strip().lower()
-
-
-def _imap_connect(integration) -> imaplib.IMAP4:
- host = str(integration.config.get("imap_host", "")).strip()
- port = int(integration.config.get("imap_port") or DEFAULT_IMAP_PORT)
- timeout = settings.CUS_AI_REQUEST_TIMEOUT
- use_ssl = integration.config.get("imap_ssl", True)
- client = imaplib.IMAP4_SSL(host, port, timeout=timeout) if use_ssl else imaplib.IMAP4(host, port, timeout=timeout)
- client.login(_address(integration), integration.secret)
- return client
-
-
-def _html_to_text(markup: str) -> str:
- text = _TAG.sub(" ", re.sub(r"(?i)
||", "\n", markup))
- return _BLANK_LINES.sub("\n\n", html.unescape(text)).strip()
-
-
-def _body_text(message) -> str:
- plain = message.get_body(preferencelist=("plain",))
- if plain is not None:
- return str(plain.get_content()).strip()
- rich = message.get_body(preferencelist=("html",))
- return _html_to_text(str(rich.get_content())) if rich is not None else ""
-
-
-def _body_html(message) -> str:
- rich = message.get_body(preferencelist=("html",))
- if rich is None:
- return ""
- return sanitize_email_html(str(rich.get_content()))
-
-
-def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundMessage | None, str]:
- """Parse one RFC822 message → (InboundMessage | None, raw Message-ID)."""
- display_name, address = parseaddr(str(message.get("From", "")))
- address = address.strip().lower()
- # Собственные письма ящика (копии отправленного) диалогом не являются.
- if not address or address == own_address:
- return None, ""
- message_id = str(message.get("Message-ID", "")).strip()
- text = _body_text(message)
- content_html = _body_html(message)
- attachments = sum(1 for _ in message.iter_attachments())
- if attachments:
- # Вложения в первой итерации не принимаются (ADR-HUB-0035).
- note = f"[Вложения не поддерживаются: {attachments} файл(ов)]"
- text = f"{text}\n\n{note}".strip()
- if not text:
- return None, message_id
- inbound = InboundMessage(
- external_id=message_id or fallback_id,
- user_id=address,
- chat_id=address,
- text=text,
- content_html=content_html,
- display_name=str(display_name).strip() or address,
- thread_meta={"subject": str(message.get("Subject", "")).strip(), "last_message_id": message_id},
- )
- return inbound, message_id
-
-
-def _status_value(client: imaplib.IMAP4, key: str) -> int:
- _, data = client.status("INBOX", f"({key})")
- match = re.search(rf"{key}\s+(\d+)", (data[0] or b"").decode("ascii", "replace"))
- return int(match.group(1)) if match else 0
-
-
-def _parse_marker(marker: str) -> tuple[int, int]:
- try:
- validity, last_uid = marker.split(":", 1)
- return int(validity), int(last_uid)
- except (ValueError, AttributeError):
- return 0, 0
-
-
-def poll_updates(integration) -> tuple[list[InboundMessage], str]:
- if not integration.secret or not integration.config.get("imap_host"):
- return [], integration.poll_marker
- try:
- client = _imap_connect(integration)
- except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
- logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
- return [], integration.poll_marker
- try:
- client.select("INBOX", readonly=True)
- validity = _status_value(client, "UIDVALIDITY")
- next_uid = _status_value(client, "UIDNEXT")
- known_validity, last_uid = _parse_marker(integration.poll_marker)
- if validity != known_validity:
- # Первый запуск или смена UIDVALIDITY: курсор — на текущий конец
- # ящика, история не импортируется (SPEC-HUB-0025 §3.3).
- return [], f"{validity}:{max(next_uid - 1, 0)}"
- _, found = client.uid("SEARCH", None, f"UID {last_uid + 1}:*")
- # IMAP-диапазон N:* всегда включает старшее письмо — отсекаем уже виденные.
- uids = sorted(int(u) for u in (found[0] or b"").split() if int(u) > last_uid)
- messages: list[InboundMessage] = []
- for uid in uids:
- _, fetched = client.uid("FETCH", str(uid), "(RFC822)")
- raw = next((part[1] for part in fetched if isinstance(part, tuple)), None)
- if raw is None:
- continue
- parsed = message_from_bytes(raw, policy=policy.default)
- inbound, _ = _normalize(parsed, own_address=_address(integration), fallback_id=f"{validity}:{uid}")
- if inbound is not None:
- messages.append(inbound)
- new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
- return messages, new_marker
- except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
- logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
- return [], integration.poll_marker
- finally:
- try:
- client.logout()
- except (imaplib.IMAP4.error, OSError):
- pass
-
-
-def _thread_meta(integration, address: str) -> dict:
- from hub_platform.conversations.models import Conversation
-
- conversation = (
- Conversation.objects.filter(connection=integration, external_chat_id=address)
- .order_by("-last_activity_at")
- .first()
- )
- return dict(conversation.transport_meta or {}) if conversation else {}
-
-
-def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
- target = (chat_id or user_id).strip().lower()
- host = str(integration.config.get("smtp_host", "")).strip()
- if not target or not host or not integration.secret:
- return False
- port = int(integration.config.get("smtp_port") or DEFAULT_SMTP_PORT)
- meta = _thread_meta(integration, target)
-
- outgoing = EmailMessage()
- outgoing["From"] = _address(integration)
- outgoing["To"] = target
- subject = str(meta.get("subject", "")).strip()
- outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else (subject or "Re: Ваше обращение")
- last_message_id = str(meta.get("last_message_id", "")).strip()
- if last_message_id:
- outgoing["In-Reply-To"] = last_message_id
- outgoing["References"] = last_message_id
- outgoing.set_content(text)
-
- timeout = settings.CUS_AI_REQUEST_TIMEOUT
- try:
- if integration.config.get("smtp_ssl", True):
- client = smtplib.SMTP_SSL(host, port, timeout=timeout)
- else:
- client = smtplib.SMTP(host, port, timeout=timeout)
- client.starttls()
- with client:
- client.login(_address(integration), integration.secret)
- client.send_message(outgoing)
- return True
- except (smtplib.SMTPException, OSError, TimeoutError) as error:
- logger.warning("Email SMTP send failed for integration %s: %s", integration.id, error)
- return False
diff --git a/apps/backend/hub_platform/conversations/view_base.py b/apps/backend/hub_platform/conversations/view_base.py
deleted file mode 100644
index df7b411..0000000
--- a/apps/backend/hub_platform/conversations/view_base.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from rest_framework.request import Request
-from rest_framework.views import APIView
-
-from hub_platform.api.permissions import HasCapability
-from hub_platform.conversations.models import Conversation
-from hub_platform.conversations.selectors import conversation_for_context
-from hub_platform.identity.audit import record_audit_event
-from hub_platform.identity.policy import require_capability
-
-
-class ConversationViewBase(APIView):
- permission_classes = [HasCapability]
- required_capability = "conversations.view"
-
- def _org(self, request: Request):
- return request.tenant_context.organization
-
- def _conversation(
- self,
- request: Request,
- conversation_id: int,
- capability: str = "conversations.view",
- ) -> Conversation:
- conversation = conversation_for_context(
- context=request.tenant_context, conversation_id=conversation_id
- )
- if not require_capability(
- request.tenant_context.membership, capability, conversation
- ):
- raise Conversation.DoesNotExist
- return conversation
-
- def _audit(
- self, request: Request, action: str, conversation: Conversation
- ) -> None:
- record_audit_event(
- action=f"conversations.{action}",
- actor=request.user,
- organization=self._org(request),
- object_type="Conversation",
- object_id=str(conversation.id),
- request=request,
- )
diff --git a/apps/backend/hub_platform/identity/migrations/0016_channel_capabilities.py b/apps/backend/hub_platform/identity/migrations/0016_channel_capabilities.py
deleted file mode 100644
index cd096bf..0000000
--- a/apps/backend/hub_platform/identity/migrations/0016_channel_capabilities.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
-#
-# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
-# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
-# момент выката.
-#
-# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
-# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
-# трогаем назначения: каждое действующее назначение профиля продолжает работать
-# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
-# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
-from django.db import migrations, models
-
-AI_VIEW = "ai.view"
-AI_MANAGE = "ai.manage"
-CHANNELS_VIEW = "channels.view"
-CHANNELS_MANAGE = "channels.manage"
-
-
-def _grant(apps, *, source: str, target: str) -> None:
- AccessProfile = apps.get_model("identity", "AccessProfile")
- AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
-
- already_granted = set(
- AccessProfileCapability.objects.filter(capability_code=target).values_list(
- "access_profile_id", flat=True
- )
- )
- # organization_id берётся у профиля, а не выводится: триггер
- # custocrm.enforce_tenant_fk требует совпадения с владельцем профиля.
- profiles = (
- AccessProfile.objects.filter(capability_links__capability_code=source)
- .values_list("id", "organization_id")
- .distinct()
- )
- AccessProfileCapability.objects.bulk_create(
- [
- AccessProfileCapability(
- access_profile_id=profile_id,
- organization_id=organization_id,
- capability_code=target,
- )
- for profile_id, organization_id in profiles
- if profile_id not in already_granted
- ]
- )
-
-
-def grant_channel_capabilities(apps, schema_editor):
- _grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
- _grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
-
-
-def revoke_channel_capabilities(apps, schema_editor):
- # В отличие от 0010 откат обязан удалить выданные строки: следом
- # восстанавливается прежний check-constraint реестра, и строки с кодом вне
- # списка сделали бы обратную миграцию невыполнимой.
- AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
- AccessProfileCapability.objects.filter(
- capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
- ).delete()
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('identity', '0015_organization_status'),
- ]
-
- operations = [
- migrations.RemoveConstraint(
- model_name='accessprofilecapability',
- name='access_profile_capability_registry',
- ),
- migrations.AddConstraint(
- model_name='accessprofilecapability',
- constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
- ),
- # Порядок важен: при откате Django исполняет операции в обратном порядке,
- # поэтому строки удаляются до восстановления старого constraint.
- migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
- ]
diff --git a/apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py b/apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py
deleted file mode 100644
index c81a105..0000000
--- a/apps/backend/hub_platform/identity/migrations/0020_employeegroup_employeegroupmember_and_more.py
+++ /dev/null
@@ -1,174 +0,0 @@
-# Generated by Django 5.2.15 on 2026-09-03 22:23
-
-import django.db.models.deletion
-import django.db.models.functions.text
-from django.db import migrations, models
-
-# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
-# грантов, которые исходно раздавала tenancy/0003 (она при откате не
-# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
-# читать identity_department и migration-тесты падают на старых состояниях.
-# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
-# откате (операции разворачиваются в обратном порядке), когда таблицы уже
-# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
-# тестами не используется, поэтому политики не восстанавливаем.
-_RESTORE_GRANTS_SQL = "\n".join(
- f"""
-ALTER TABLE {table} OWNER TO custocrm_schema;
-GRANT ALL ON {table} TO custocrm_schema;
-GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO custocrm_runtime_app;
-"""
- for table in (
- "identity_department",
- "identity_accessprofile",
- "identity_accessprofilecapability",
- "identity_employeeaccessassignment",
- )
-)
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('ai', '0014_remove_knowledgedepartment_department_and_more'),
- ('channels', '0006_remove_channel_department'),
- ('identity', '0019_drop_sales_capabilities'),
- ('notifications', '0008_remove_notification_department'),
- ('products', '0012_delete_productdepartment'),
- ('support_portals', '0008_remove_supportportal_department'),
- ]
-
- operations = [
- migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
- migrations.CreateModel(
- name='EmployeeGroup',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=120)),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ],
- options={
- 'ordering': ['name'],
- },
- ),
- migrations.CreateModel(
- name='EmployeeGroupMember',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ],
- ),
- # Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
- # state хранит constraint на несуществующее поле и обратная миграция
- # (reverse DeleteModel в migration-тестах) падает при create_model.
- migrations.RemoveConstraint(
- model_name='accessprofile',
- name='uniq_access_profile_org_name_ci',
- ),
- migrations.RemoveConstraint(
- model_name='accessprofilecapability',
- name='uniq_access_profile_capability',
- ),
- migrations.RemoveConstraint(
- model_name='department',
- name='uniq_department_org_code',
- ),
- migrations.RemoveConstraint(
- model_name='employeeaccessassignment',
- name='access_assignment_scope_department',
- ),
- migrations.RemoveConstraint(
- model_name='employeeaccessassignment',
- name='uniq_active_org_access_assignment',
- ),
- migrations.RemoveConstraint(
- model_name='employeeaccessassignment',
- name='uniq_active_dept_access_assignment',
- ),
- migrations.RemoveField(
- model_name='accessprofile',
- name='organization',
- ),
- migrations.RemoveField(
- model_name='accessprofilecapability',
- name='access_profile',
- ),
- migrations.RemoveField(
- model_name='employeeaccessassignment',
- name='access_profile',
- ),
- migrations.RemoveField(
- model_name='accessprofilecapability',
- name='organization',
- ),
- migrations.RemoveField(
- model_name='department',
- name='organization',
- ),
- # Сначала снимаем constraint, зависящий от primary_department: дроп
- # колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
- migrations.RemoveConstraint(
- model_name='organizationmembership',
- name='owner_is_company_level',
- ),
- migrations.RemoveField(
- model_name='organizationmembership',
- name='primary_department',
- ),
- migrations.RemoveField(
- model_name='employeeaccessassignment',
- name='department',
- ),
- migrations.RemoveField(
- model_name='employeeaccessassignment',
- name='assigned_by',
- ),
- migrations.RemoveField(
- model_name='employeeaccessassignment',
- name='employee',
- ),
- migrations.RemoveField(
- model_name='employeeaccessassignment',
- name='organization',
- ),
- migrations.AddField(
- model_name='employeegroup',
- name='organization',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
- ),
- migrations.AddField(
- model_name='employeegroupmember',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
- ),
- migrations.AddField(
- model_name='employeegroupmember',
- name='group',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
- ),
- migrations.AddField(
- model_name='employeegroupmember',
- name='organization',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
- ),
- migrations.DeleteModel(
- name='AccessProfile',
- ),
- migrations.DeleteModel(
- name='AccessProfileCapability',
- ),
- migrations.DeleteModel(
- name='Department',
- ),
- migrations.DeleteModel(
- name='EmployeeAccessAssignment',
- ),
- migrations.AddConstraint(
- model_name='employeegroup',
- constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
- ),
- migrations.AddConstraint(
- model_name='employeegroupmember',
- constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
- ),
- ]
diff --git a/apps/backend/hub_platform/integrations/admin.py b/apps/backend/hub_platform/integrations/admin.py
deleted file mode 100644
index 65270e3..0000000
--- a/apps/backend/hub_platform/integrations/admin.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from django.contrib import admin
-
-from hub_platform.integrations.models import Integration
-
-
-@admin.register(Integration)
-class IntegrationAdmin(admin.ModelAdmin):
- list_display = ("name", "provider", "kind", "status", "last_checked_at")
- list_filter = ("provider", "kind", "status")
- search_fields = ("name",)
- readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
diff --git a/apps/backend/hub_platform/integrations/apps.py b/apps/backend/hub_platform/integrations/apps.py
deleted file mode 100644
index ec69a4a..0000000
--- a/apps/backend/hub_platform/integrations/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-
-
-class IntegrationsConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- label = "integrations"
- name = "hub_platform.integrations"
- verbose_name = "Integrations: providers and connections"
diff --git a/apps/backend/hub_platform/integrations/migrations/0001_initial.py b/apps/backend/hub_platform/integrations/migrations/0001_initial.py
deleted file mode 100644
index fefea36..0000000
--- a/apps/backend/hub_platform/integrations/migrations/0001_initial.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# Generated by Django 5.2.15 on 2026-06-27 21:12
-
-import django.db.models.deletion
-import hub_platform.identity.crypto
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- ('identity', '0006_alter_employeeprofile_totp_secret'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='Integration',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
- ('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
- ('name', models.CharField(max_length=255)),
- ('secret', hub_platform.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
- ('config', models.JSONField(blank=True, default=dict)),
- ('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
- ('last_checked_at', models.DateTimeField(blank=True, null=True)),
- ('last_error', models.TextField(blank=True)),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
- ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
- ],
- options={
- 'ordering': ['provider', 'name'],
- 'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
- },
- ),
- ]
diff --git a/apps/backend/hub_platform/integrations/test_email.py b/apps/backend/hub_platform/integrations/test_email.py
deleted file mode 100644
index 27b6f04..0000000
--- a/apps/backend/hub_platform/integrations/test_email.py
+++ /dev/null
@@ -1,137 +0,0 @@
-"""Email-подключение: конфигурация, проверка и сериализация (SPEC-HUB-0025 §5)."""
-
-from unittest import mock
-
-from django.core.exceptions import ValidationError
-from django.test import TestCase
-
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.integrations import checks
-from hub_platform.integrations.models import IntegrationKind, IntegrationProvider, IntegrationStatus
-from hub_platform.integrations.serializers import integration_payload
-from hub_platform.integrations.services import IntegrationInput, create_integration
-from hub_platform.testing import system_tenant_context
-
-EMAIL_INPUT = {
- "email": "Support@edevs.tech",
- "imapHost": "imap.yandex.ru",
- "smtpHost": "smtp.yandex.ru",
-}
-
-
-class EmailConfigTests(TestCase):
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.context = system_tenant_context(Organization.objects.get(slug="edevs"))
-
- def _create(self, *, secret="app-password", config=None):
- return create_integration(
- context=self.context,
- data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret=secret, config=config or dict(EMAIL_INPUT)),
- )
-
- def test_email_is_messenger_kind_with_normalized_config(self) -> None:
- integration = self._create()
- self.assertEqual(integration.kind, IntegrationKind.MESSENGER)
- # camelCase → snake_case, адрес приводится к lowercase, порты/SSL по умолчанию.
- self.assertEqual(
- integration.config,
- {
- "email": "support@edevs.tech",
- "imap_host": "imap.yandex.ru",
- "imap_port": 993,
- "imap_ssl": True,
- "smtp_host": "smtp.yandex.ru",
- "smtp_port": 465,
- "smtp_ssl": True,
- },
- )
-
- def test_secret_required_on_create(self) -> None:
- with self.assertRaises(ValidationError):
- self._create(secret="")
-
- def test_hosts_and_address_required(self) -> None:
- with self.assertRaises(ValidationError):
- self._create(config={"email": "a@b.c", "imapHost": "imap.b.c"})
-
- def test_notifications_purpose_rejected(self) -> None:
- # Email не может быть сервисным ботом уведомлений (ADR-HUB-0035, границы).
- with self.assertRaises(ValidationError):
- self._create(config={**EMAIL_INPUT, "purpose": "notifications"})
-
- def test_serializer_exposes_email_config(self) -> None:
- payload = integration_payload(self._create())
- config = payload["config"]
- self.assertEqual(config["email"], "support@edevs.tech")
- self.assertEqual(config["imapHost"], "imap.yandex.ru")
- self.assertEqual(config["imapPort"], 993)
- self.assertTrue(config["smtpSsl"])
- self.assertTrue(payload["hasSecret"])
-
-
-class EmailCheckTests(TestCase):
- """check_email: успех только когда успешны ОБЕ стороны (IMAP и SMTP)."""
-
- CONFIG = {
- "email": "support@edevs.tech",
- "imap_host": "imap.test",
- "imap_port": 993,
- "imap_ssl": True,
- "smtp_host": "smtp.test",
- "smtp_port": 465,
- "smtp_ssl": True,
- }
-
- def test_both_sides_ok(self) -> None:
- with (
- mock.patch.object(checks.imaplib, "IMAP4_SSL") as imap_cls,
- mock.patch.object(checks.smtplib, "SMTP_SSL") as smtp_cls,
- ):
- ok, detail, meta = checks.check_email(secret="pw", config=self.CONFIG)
- self.assertTrue(ok)
- self.assertEqual(detail, "Email: support@edevs.tech")
- imap_cls.return_value.login.assert_called_once_with("support@edevs.tech", "pw")
- smtp_cls.return_value.login.assert_called_once_with("support@edevs.tech", "pw")
-
- def test_imap_failure_reported(self) -> None:
- with mock.patch.object(checks.imaplib, "IMAP4_SSL", side_effect=OSError("connection refused")):
- ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)
- self.assertFalse(ok)
- self.assertIn("IMAP:", detail)
-
- def test_smtp_failure_reported(self) -> None:
- with (
- mock.patch.object(checks.imaplib, "IMAP4_SSL"),
- mock.patch.object(checks.smtplib, "SMTP_SSL", side_effect=OSError("connection refused")),
- ):
- ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)
- self.assertFalse(ok)
- self.assertIn("SMTP:", detail)
-
- def test_missing_secret_fails(self) -> None:
- ok, detail, _ = checks.check_email(secret="", config=self.CONFIG)
- self.assertFalse(ok)
- self.assertIn("пароль", detail)
-
-
-class EmailTestIntegrationDispatchTests(TestCase):
- """test_integration диспетчеризует EMAIL на check_email (полный config)."""
-
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.context = system_tenant_context(Organization.objects.get(slug="edevs"))
-
- def test_email_check_updates_status(self) -> None:
- from hub_platform.integrations.services import test_integration as run_integration_test
-
- integration = create_integration(
- context=self.context,
- data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret="pw", config=dict(EMAIL_INPUT)),
- )
- with mock.patch.object(checks, "check_email", return_value=(True, "Email: support@edevs.tech", {})) as check:
- checked = run_integration_test(context=self.context, integration=integration)
- check.assert_called_once()
- self.assertEqual(checked.status, IntegrationStatus.OK)
- self.assertEqual(checked.last_error, "")
diff --git a/apps/backend/hub_platform/integrations/tests.py b/apps/backend/hub_platform/integrations/tests.py
deleted file mode 100644
index ee10a4c..0000000
--- a/apps/backend/hub_platform/integrations/tests.py
+++ /dev/null
@@ -1,357 +0,0 @@
-import json
-import urllib.request
-from unittest import mock
-
-from django.core.exceptions import ValidationError
-from django.test import TestCase
-
-from hub_platform.identity.bootstrap import bootstrap_edevs_owner
-from hub_platform.identity.models import Organization
-from hub_platform.integrations import checks
-from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
-from hub_platform.integrations.serializers import integration_payload
-from hub_platform.integrations.services import (
- IntegrationInput,
- create_integration,
- test_integration as run_integration_test,
- update_integration,
-)
-from hub_platform.testing import system_tenant_context
-
-
-def _fake_response(status: int, body: dict):
- response = mock.MagicMock()
- response.status = status
- response.read.return_value = json.dumps(body).encode("utf-8")
- return response
-
-
-class WebIntegrationCheckTests(TestCase):
- """«Проверить» для Web-виджета: внешнего API нет — валидируем привязку к
- каналу и самостоятельный widget entry point подключения."""
-
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.organization = Organization.objects.get(slug="edevs")
- self.context = system_tenant_context(self.organization)
- from hub_platform.channels.models import Channel
-
- self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
-
- def _web(self, name: str, channel=None, origins=("edevs.tech",)) -> Integration:
- return create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.WEB,
- name=name,
- channel_id=channel.id if channel else None,
- config={"allowedOrigins": list(origins)},
- ),
- )
-
- def test_web_without_channel_fails(self) -> None:
- integration = run_integration_test(
- context=self.context, integration=self._web("Виджет", channel=None)
- )
- self.assertEqual(integration.status, IntegrationStatus.ERROR)
- self.assertIn("не привязано к каналу", integration.last_error)
-
- def test_web_bound_to_channel_is_ok(self) -> None:
- integration = run_integration_test(
- context=self.context, integration=self._web("Виджет", channel=self.channel)
- )
- self.assertEqual(integration.status, IntegrationStatus.OK)
- self.assertEqual(integration.last_error, "")
- self.assertEqual(integration.web_chat_widget.status, "PUBLISHED")
- self.assertTrue(integration_payload(integration)["webChatWidget"]["publicKey"])
-
- def test_two_web_connections_on_one_channel_are_independent(self) -> None:
- first = run_integration_test(
- context=self.context,
- integration=self._web("A-виджет", channel=self.channel),
- )
- second = run_integration_test(
- context=self.context, integration=self._web("B-виджет", channel=self.channel)
- )
- self.assertEqual(first.status, IntegrationStatus.OK)
- self.assertEqual(second.status, IntegrationStatus.OK)
- self.assertNotEqual(
- first.web_chat_widget.public_key,
- second.web_chat_widget.public_key,
- )
-
- def test_web_without_allowed_origins_fails(self) -> None:
- """Пустой allowed_origins в проде запрещает все домены, и виджет молча
- показывает «Чат временно недоступен» — проверка обязана это ловить."""
- integration = run_integration_test(
- context=self.context,
- integration=self._web("Виджет", channel=self.channel, origins=()),
- )
- self.assertEqual(integration.status, IntegrationStatus.ERROR)
- self.assertIn("Не заданы разрешённые домены", integration.last_error)
- self.assertEqual(integration.web_chat_widget.status, "DRAFT")
-
- def test_allowed_origins_reach_the_widget(self) -> None:
- integration = run_integration_test(
- context=self.context,
- integration=self._web(
- "Виджет", channel=self.channel, origins=("edevs.tech", "*.edevs.tech")
- ),
- )
- self.assertEqual(integration.status, IntegrationStatus.OK)
- self.assertEqual(
- integration.web_chat_widget.allowed_origins, ["edevs.tech", "*.edevs.tech"]
- )
- self.assertEqual(
- integration_payload(integration)["config"]["allowedOrigins"],
- ["edevs.tech", "*.edevs.tech"],
- )
-
- def test_saving_connection_keeps_the_widget_published(self) -> None:
- """Регрессия: раздача виджета требует PUBLISHED + OK (webchat.views), а правка
- подключения не должна ни ронять чат на сайте до ручного «Проверить», ни
- обнулять домены — именно так виджеты уходили в «Чат временно недоступен»."""
- integration = self._web("Виджет", channel=self.channel)
- self.assertEqual(integration.status, IntegrationStatus.OK)
- self.assertEqual(integration.web_chat_widget.status, "PUBLISHED")
-
- renamed = update_integration(
- context=self.context,
- integration=integration,
- data=IntegrationInput(
- provider=IntegrationProvider.WEB,
- name="Виджет · переименован",
- channel_id=self.channel.id,
- config={"allowedOrigins": ["edevs.tech"]},
- ),
- )
- self.assertEqual(renamed.status, IntegrationStatus.OK)
- self.assertEqual(renamed.web_chat_widget.status, "PUBLISHED")
- self.assertEqual(renamed.config["allowed_domains"], ["edevs.tech"])
- self.assertEqual(renamed.web_chat_widget.allowed_origins, ["edevs.tech"])
-
-class ProxyConfigTests(TestCase):
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.organization = Organization.objects.get(slug="edevs")
- self.context = system_tenant_context(self.organization)
-
- def test_proxy_url_is_persisted_in_config(self) -> None:
- integration = create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.OPENROUTER,
- name="OpenRouter",
- secret="sk-test",
- config={"baseUrl": "https://openrouter.ai/api/v1", "proxyUrl": "http://user:pass@host:8080"},
- ),
- )
- self.assertEqual(integration.config["proxy_url"], "http://user:pass@host:8080")
-
- def test_update_clears_proxy_when_empty(self) -> None:
- integration = create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.OPENROUTER,
- name="OpenRouter",
- secret="sk-test",
- config={"proxyUrl": "http://host:8080"},
- ),
- )
- updated = update_integration(
- context=self.context,
- integration=integration,
- data=IntegrationInput(provider=IntegrationProvider.OPENROUTER, name="OpenRouter", config={"proxyUrl": ""}),
- )
- self.assertNotIn("proxy_url", updated.config)
-
- def test_serializer_exposes_proxy_url(self) -> None:
- integration = create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.OPENROUTER,
- name="OpenRouter",
- secret="sk-test",
- config={"proxyUrl": "http://host:8080"},
- ),
- )
- self.assertEqual(integration_payload(integration)["config"]["proxyUrl"], "http://host:8080")
-
-
-def _patched_opener(captured: dict, body: dict):
- def fake_build_opener(proxy_url):
- captured["proxy_url"] = proxy_url
- opener = mock.MagicMock()
- ctx = mock.MagicMock()
- ctx.__enter__.return_value = _fake_response(200, body)
- opener.open.return_value = ctx
- return opener
-
- return fake_build_opener
-
-
-class CheckProxyTransportTests(TestCase):
- """check_* должны прокидывать proxy_url в единый opener (build_opener)."""
-
- def test_check_openrouter_uses_proxy(self) -> None:
- captured = {}
- with mock.patch("hub_platform.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})):
- ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="http://proxy:8080")
-
- self.assertTrue(ok)
- self.assertEqual(captured["proxy_url"], "http://proxy:8080")
-
- def test_check_openrouter_without_proxy_passes_empty(self) -> None:
- captured = {}
- with mock.patch("hub_platform.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})):
- checks.check_openrouter(secret="sk-test", base_url="", proxy_url="")
-
- self.assertEqual(captured["proxy_url"], "")
-
- def test_check_openrouter_supports_socks_url(self) -> None:
- captured = {}
- with mock.patch("hub_platform.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})):
- ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="socks5://proxy:1080")
-
- self.assertTrue(ok)
- self.assertEqual(captured["proxy_url"], "socks5://proxy:1080")
-
-
-class OpenRouterProviderProxyTests(TestCase):
- def test_provider_routes_through_proxy_handler(self) -> None:
- from hub_platform.ai.provider.openrouter import OpenRouterProvider
-
- captured = {}
- provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080")
- # После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7):
- # мокаем именно его build_opener.
- with mock.patch("hub_platform.ai.provider.openai_http.build_opener", side_effect=_patched_opener(captured, {"choices": [{"message": {"content": "ok"}}], "model": "x"})):
- result = provider.chat(messages=[], model="x")
-
- self.assertEqual(result.text, "ok")
- self.assertEqual(captured["proxy_url"], "http://proxy:8080")
-
-
-class BuildOpenerSocksTests(TestCase):
- def test_http_scheme_uses_proxy_handler(self) -> None:
- from hub_platform.integrations.proxy import build_opener
-
- opener = build_opener("http://proxy:8080")
- self.assertTrue(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers))
-
- def test_socks5_scheme_builds_socks_handler(self) -> None:
- from hub_platform.integrations.proxy import build_opener
-
- opener = build_opener("socks5://user:pass@host:1080")
- # PySocks установлен → handler строится без ошибок и не является ProxyHandler.
- self.assertFalse(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers))
-
- def test_socks_without_pysocks_raises_value_error(self) -> None:
- from hub_platform.integrations import proxy
- from hub_platform.integrations.proxy import build_opener
-
- with mock.patch.object(proxy, "_import_socks", side_effect=ValueError("no PySocks")):
- with self.assertRaises(ValueError):
- build_opener("socks5://host:1080")
-
-
-class CustomIntegrationTests(TestCase):
- """Generic OpenAI-compatible BYOK provider (ADR-HUB-0034, SPEC-HUB-0024 §5).
-
- Covers the three required fields (endpoint, API key, model), runtime
- reading of the model field (SPEC #2, #5 — closing the as-built gap where
- «Модель по умолчанию» was decorative), and the connectivity check against
- an arbitrary OpenAI-compatible endpoint.
- """
-
- def setUp(self) -> None:
- bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
- self.organization = Organization.objects.get(slug="edevs")
- self.context = system_tenant_context(self.organization)
-
- def test_custom_persists_endpoint_and_model(self) -> None:
- integration = create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.CUSTOM,
- name="Мой провайдер",
- secret="sk-custom",
- config={
- "baseUrl": "https://api.example.com/v1",
- "defaultModel": "local-llama-3",
- },
- ),
- )
- # Модель — отдельное рабочее поле (ADR-HUB-0034 §4), читается в рантайме.
- self.assertEqual(integration.config["base_url"], "https://api.example.com/v1")
- self.assertEqual(integration.config["default_model"], "local-llama-3")
- self.assertEqual(integration.kind, IntegrationKind.LLM_PROVIDER)
-
- def test_custom_requires_model_free_text(self) -> None:
- with self.assertRaises(ValidationError):
- create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.CUSTOM,
- name="Мой провайдер",
- secret="sk-custom",
- config={"baseUrl": "https://api.example.com/v1"},
- ),
- )
-
- def test_custom_requires_base_url_and_api_key(self) -> None:
- with self.assertRaises(ValidationError):
- create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.CUSTOM,
- name="Без endpoint",
- secret="sk-custom",
- config={"defaultModel": "local-model"},
- ),
- )
- with self.assertRaises(ValidationError):
- create_integration(
- context=self.context,
- data=IntegrationInput(
- provider=IntegrationProvider.CUSTOM,
- name="Без ключа",
- secret="",
- config={
- "baseUrl": "https://api.example.com/v1",
- "defaultModel": "local-model",
- },
- ),
- )
-
- def test_check_custom_lists_models_on_openai_shape(self) -> None:
- captured = {}
- with mock.patch("hub_platform.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": [{"id": "local-llama-3"}]})):
- ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1")
-
- self.assertTrue(ok)
- self.assertIn("1 моделей", detail)
-
- def test_check_custom_without_secret_fails(self) -> None:
- ok, detail, meta = checks.check_custom(secret="", base_url="https://api.example.com/v1")
- self.assertFalse(ok)
- self.assertEqual(detail, "Не указан API-ключ")
-
- def test_check_custom_without_base_url_fails(self) -> None:
- ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="")
- self.assertFalse(ok)
- self.assertEqual(detail, "Не указан Base URL")
-
- def test_check_custom_non_200_is_error(self) -> None:
- response = mock.MagicMock()
- response.status = 401
- response.read.return_value = b"{}"
- opener = mock.MagicMock()
- ctx = mock.MagicMock()
- ctx.__enter__.return_value = response
- opener.open.return_value = ctx
- with mock.patch("hub_platform.integrations.checks.build_opener", return_value=opener):
- ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1")
-
- self.assertFalse(ok)
- self.assertIn("401", detail)
diff --git a/apps/backend/hub_platform/integrations/urls.py b/apps/backend/hub_platform/integrations/urls.py
deleted file mode 100644
index e07914b..0000000
--- a/apps/backend/hub_platform/integrations/urls.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.urls import path
-
-from hub_platform.integrations import views
-
-urlpatterns = [
- path("", views.IntegrationListView.as_view(), name="integration-list"),
- path("/", views.IntegrationDetailView.as_view(), name="integration-detail"),
- path("/test/", views.IntegrationTestView.as_view(), name="integration-test"),
-]
diff --git a/apps/backend/hub_platform/notifications/admin.py b/apps/backend/hub_platform/notifications/admin.py
deleted file mode 100644
index 6ad6437..0000000
--- a/apps/backend/hub_platform/notifications/admin.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from django.contrib import admin
-
-from hub_platform.notifications.models import Notification, NotificationRead
-
-
-@admin.register(Notification)
-class NotificationAdmin(admin.ModelAdmin):
- list_display = ("type", "audience", "level", "title", "organization", "created_at")
- list_filter = ("type", "audience", "level")
- search_fields = ("title", "body", "dedup_key")
-
-
-@admin.register(NotificationRead)
-class NotificationReadAdmin(admin.ModelAdmin):
- list_display = ("notification", "user", "read_at")
diff --git a/apps/backend/hub_platform/notifications/apps.py b/apps/backend/hub_platform/notifications/apps.py
deleted file mode 100644
index ac7a800..0000000
--- a/apps/backend/hub_platform/notifications/apps.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from django.apps import AppConfig
-
-
-class NotificationsConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- label = "notifications"
- name = "hub_platform.notifications"
- verbose_name = "Notifications"
-
- def ready(self) -> None:
- from hub_platform.notifications import event_handlers # noqa: F401 (register outbox handlers)
diff --git a/apps/backend/hub_platform/notifications/serializers.py b/apps/backend/hub_platform/notifications/serializers.py
deleted file mode 100644
index a41709e..0000000
--- a/apps/backend/hub_platform/notifications/serializers.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from hub_platform.notifications.models import Notification
-
-
-def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:
- return {
- "id": notification.id,
- "type": notification.type,
- "level": notification.level,
- "title": notification.title,
- "body": notification.body,
- "targetRoute": notification.target_route,
- "targetId": notification.target_id,
- "createdAt": notification.created_at.isoformat(),
- "unread": unread,
- }
diff --git a/apps/backend/hub_platform/products/migrations/0007_product_support_token_secret.py b/apps/backend/hub_platform/products/migrations/0007_product_support_token_secret.py
deleted file mode 100644
index d242801..0000000
--- a/apps/backend/hub_platform/products/migrations/0007_product_support_token_secret.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 5.2.15 on 2026-07-08 21:29
-
-import hub_platform.identity.crypto
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('products', '0006_product_ingest_token_hash'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='product',
- name='support_token_secret',
- field=hub_platform.identity.crypto.EncryptedCharField(blank=True, max_length=512),
- ),
- ]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0007_platform_row_select_policies.py b/apps/backend/hub_platform/tenancy/migrations/0007_platform_row_select_policies.py
deleted file mode 100644
index fdfddc3..0000000
--- a/apps/backend/hub_platform/tenancy/migrations/0007_platform_row_select_policies.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from django.db import migrations
-
-# Django ORM inserts use INSERT ... RETURNING id; PostgreSQL applies SELECT
-# policies to rows returned by RETURNING, so the app role needs SELECT
-# visibility of the platform-scope rows it is allowed to insert
-# (custocrm_app_platform_audit_insert / custocrm_app_platform_outbox_insert).
-# Without these policies a login-failed audit write fails with
-# "new row violates row-level security policy".
-CREATE_POLICIES = """
-DROP POLICY IF EXISTS custocrm_app_platform_audit_select ON identity_auditevent;
-CREATE POLICY custocrm_app_platform_audit_select ON identity_auditevent
- FOR SELECT TO custocrm_runtime_app USING (organization_id IS NULL);
-DROP POLICY IF EXISTS custocrm_app_platform_outbox_select ON events_outboxevent;
-CREATE POLICY custocrm_app_platform_outbox_select ON events_outboxevent
- FOR SELECT TO custocrm_runtime_app
- USING (ownership = 'PLATFORM' AND organization_id IS NULL);
-"""
-
-DROP_POLICIES = """
-DROP POLICY IF EXISTS custocrm_app_platform_audit_select ON identity_auditevent;
-DROP POLICY IF EXISTS custocrm_app_platform_outbox_select ON events_outboxevent;
-"""
-
-
-class Migration(migrations.Migration):
- dependencies = [("tenancy", "0006_storage_reserved_bytes")]
- operations = [migrations.RunSQL(CREATE_POLICIES, DROP_POLICIES)]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0009_tenant_guards_security_definer.py b/apps/backend/hub_platform/tenancy/migrations/0009_tenant_guards_security_definer.py
deleted file mode 100644
index 9b4712b..0000000
--- a/apps/backend/hub_platform/tenancy/migrations/0009_tenant_guards_security_definer.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Guard-функции C04 должны видеть строки поверх RLS (SECURITY DEFINER).
-
-Функции проверяют целостность связей между тенантами и обязаны читать
-родительскую строку независимо от политик вызывающей роли. Как SECURITY
-INVOKER их внутренний SELECT подчинялся RLS: из кросс-тенантного пути без
-выставленного `custocrm.organization_id` (outbox-воркер claim'ит событие
-любой организации) родитель не виден, parent_org = NULL, и проверка
-`IS DISTINCT FROM` ложно срабатывала — легитимный UPDATE падал с
-`cross-tenant relation`, что уводило воркер в краш-петлю.
-
-SECURITY DEFINER исполняет их от custocrm_schema (policy USING (true)),
-поэтому сравнение идёт по фактическим данным. Проверка не ослабляется:
-настоящее нарушение по-прежнему приводит к RAISE. search_path закреплён,
-как того требует безопасность SECURITY DEFINER-функций.
-"""
-
-from django.db import migrations
-
-
-GUARD_FUNCTIONS = (
- "custocrm.enforce_tenant_fk()",
- "custocrm.enforce_tenant_user()",
- "custocrm.enforce_tenant_pair()",
-)
-
-
-def set_security_definer(apps, schema_editor):
- for function in GUARD_FUNCTIONS:
- schema_editor.execute(
- f"ALTER FUNCTION {function} SECURITY DEFINER SET search_path = public, pg_temp"
- )
-
-
-def set_security_invoker(apps, schema_editor):
- for function in GUARD_FUNCTIONS:
- schema_editor.execute(
- f"ALTER FUNCTION {function} SECURITY INVOKER RESET search_path"
- )
-
-
-class Migration(migrations.Migration):
- dependencies = [("tenancy", "0008_ai_knowledge_scope_guards")]
- operations = [migrations.RunPython(set_security_definer, set_security_invoker)]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0012_organization_settings_rls.py b/apps/backend/hub_platform/tenancy/migrations/0012_organization_settings_rls.py
deleted file mode 100644
index b7139d3..0000000
--- a/apps/backend/hub_platform/tenancy/migrations/0012_organization_settings_rls.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from django.db import migrations
-
-
-FORWARD_SQL = """
-ALTER TABLE identity_organization OWNER TO custocrm_schema;
-ALTER TABLE identity_organization ENABLE ROW LEVEL SECURITY;
-ALTER TABLE identity_organization FORCE ROW LEVEL SECURITY;
-REVOKE ALL ON identity_organization FROM PUBLIC;
-
-GRANT SELECT, UPDATE ON identity_organization TO custocrm_runtime_app;
-GRANT SELECT, INSERT, UPDATE ON identity_organization TO custocrm_runtime_platform;
-GRANT ALL ON identity_organization TO custocrm_schema;
-
-DROP POLICY IF EXISTS custocrm_organization_app_select ON identity_organization;
-CREATE POLICY custocrm_organization_app_select ON identity_organization
- FOR SELECT TO custocrm_runtime_app
- USING (true);
-
-DROP POLICY IF EXISTS custocrm_organization_app_update ON identity_organization;
-CREATE POLICY custocrm_organization_app_update ON identity_organization
- FOR UPDATE TO custocrm_runtime_app
- USING (id = custocrm.current_organization_id())
- WITH CHECK (id = custocrm.current_organization_id());
-
-DROP POLICY IF EXISTS custocrm_organization_platform ON identity_organization;
-CREATE POLICY custocrm_organization_platform ON identity_organization
- FOR ALL TO custocrm_runtime_platform
- USING (true)
- WITH CHECK (true);
-
-DROP POLICY IF EXISTS custocrm_organization_schema ON identity_organization;
-CREATE POLICY custocrm_organization_schema ON identity_organization
- FOR ALL TO custocrm_schema
- USING (true)
- WITH CHECK (true);
-"""
-
-
-REVERSE_SQL = """
-DROP POLICY IF EXISTS custocrm_organization_app_select ON identity_organization;
-DROP POLICY IF EXISTS custocrm_organization_app_update ON identity_organization;
-DROP POLICY IF EXISTS custocrm_organization_platform ON identity_organization;
-DROP POLICY IF EXISTS custocrm_organization_schema ON identity_organization;
-ALTER TABLE identity_organization NO FORCE ROW LEVEL SECURITY;
-ALTER TABLE identity_organization DISABLE ROW LEVEL SECURITY;
-REVOKE UPDATE ON identity_organization FROM custocrm_runtime_app;
-"""
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("identity", "0017_organization_branding"),
- ("tenancy", "0011_support_portal_host_directory"),
- ]
-
- operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
diff --git a/apps/backend/hub_platform/tenancy/migrations/0013_storage_reservation_rls.py b/apps/backend/hub_platform/tenancy/migrations/0013_storage_reservation_rls.py
deleted file mode 100644
index de20a42..0000000
--- a/apps/backend/hub_platform/tenancy/migrations/0013_storage_reservation_rls.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from django.db import migrations
-
-
-FORWARD_SQL = """
-ALTER TABLE tenancy_storagereservation OWNER TO custocrm_schema;
-ALTER TABLE tenancy_storagereservation ENABLE ROW LEVEL SECURITY;
-ALTER TABLE tenancy_storagereservation FORCE ROW LEVEL SECURITY;
-REVOKE ALL ON tenancy_storagereservation FROM PUBLIC;
-
-GRANT SELECT, INSERT, UPDATE, DELETE
- ON tenancy_storagereservation TO custocrm_runtime_app;
-GRANT ALL ON tenancy_storagereservation TO custocrm_schema;
-GRANT USAGE, SELECT ON SEQUENCE tenancy_storagereservation_id_seq
- TO custocrm_runtime_app, custocrm_schema;
-
-DROP POLICY IF EXISTS custocrm_tenant_isolation ON tenancy_storagereservation;
-CREATE POLICY custocrm_tenant_isolation ON tenancy_storagereservation
- FOR ALL TO custocrm_runtime_app
- USING (organization_id = custocrm.current_organization_id())
- WITH CHECK (organization_id = custocrm.current_organization_id());
-
-DROP POLICY IF EXISTS custocrm_schema_access ON tenancy_storagereservation;
-CREATE POLICY custocrm_schema_access ON tenancy_storagereservation
- FOR ALL TO custocrm_schema
- USING (true)
- WITH CHECK (true);
-"""
-
-
-REVERSE_SQL = """
-DROP POLICY IF EXISTS custocrm_tenant_isolation ON tenancy_storagereservation;
-DROP POLICY IF EXISTS custocrm_schema_access ON tenancy_storagereservation;
-ALTER TABLE tenancy_storagereservation NO FORCE ROW LEVEL SECURITY;
-ALTER TABLE tenancy_storagereservation DISABLE ROW LEVEL SECURITY;
-REVOKE ALL ON tenancy_storagereservation FROM custocrm_runtime_app;
-REVOKE USAGE, SELECT ON SEQUENCE tenancy_storagereservation_id_seq
- FROM custocrm_runtime_app;
-"""
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("tenancy", "0012_organization_settings_rls"),
- ]
-
- operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
diff --git a/apps/backend/hub_platform/tenancy/test_rls.py b/apps/backend/hub_platform/tenancy/test_rls.py
deleted file mode 100644
index 27c920f..0000000
--- a/apps/backend/hub_platform/tenancy/test_rls.py
+++ /dev/null
@@ -1,266 +0,0 @@
-from django.db import DatabaseError, connection, transaction
-from django.test import TransactionTestCase
-
-from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
-from hub_platform.ai.models import AIAgent, Knowledge
-from hub_platform.channels.models import Channel
-from hub_platform.identity.group_models import EmployeeGroup
-from hub_platform.identity.models import (
- AuditEvent,
- AuditResult,
- EmployeeRole,
- HumanUser,
- Organization,
- OrganizationMembership,
-)
-from hub_platform.products.models import Product
-from hub_platform.tenancy.database import current_tenant_id, set_local_tenant
-from hub_platform.tenancy.models import StorageReservation
-from hub_platform.testing import TenantAPIClient
-
-
-class RowLevelSecurityTests(TransactionTestCase):
- reset_sequences = True
-
- def setUp(self) -> None:
- self.first = Organization.objects.create(name="First", slug="rls-first")
- self.second = Organization.objects.create(name="Second", slug="rls-second")
- self.first_product = Product.objects.create(
- organization=self.first,
- code="first",
- name="First product",
- )
- self.second_product = Product.objects.create(
- organization=self.second,
- code="second",
- name="Second product",
- )
- self.second_group = EmployeeGroup.objects.create(
- organization=self.second,
- name="Foreign",
- )
- self.user = HumanUser.objects.create_user(email="rls@example.test")
- self.membership = OrganizationMembership.objects.create(
- organization=self.first,
- user=self.user,
- role=EmployeeRole.OWNER,
- position_title="Owner",
- )
-
- @staticmethod
- def _set_role(role: str) -> None:
- if role not in {"custocrm_runtime_app", "custocrm_runtime_platform"}:
- raise ValueError("Unexpected test role")
- with connection.cursor() as cursor:
- cursor.execute(f"SET LOCAL ROLE {role}")
-
- def test_runtime_roles_are_not_owners_or_bypassrls(self) -> None:
- with connection.cursor() as cursor:
- cursor.execute(
- "SELECT rolname, rolsuper, rolbypassrls, "
- "pg_has_role(rolname, 'custocrm_schema', 'MEMBER') FROM pg_roles "
- "WHERE rolname IN ('custocrm_runtime_app', 'custocrm_runtime_platform') "
- "ORDER BY rolname"
- )
- roles = cursor.fetchall()
- cursor.execute(
- "SELECT tableowner FROM pg_tables "
- "WHERE schemaname = 'public' AND tablename = 'identity_product'"
- )
- owner = cursor.fetchone()[0]
- self.assertEqual(len(roles), 2)
- self.assertTrue(
- all(
- not superuser and not bypass and not schema_member
- for _, superuser, bypass, schema_member in roles
- )
- )
- self.assertEqual(owner, "custocrm_schema")
-
- def test_app_role_is_fail_closed_and_scoped(self) -> None:
- with transaction.atomic():
- self._set_role("custocrm_runtime_app")
- self.assertEqual(Product.objects.count(), 0)
-
- with transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- self.assertEqual(
- list(Product.objects.values_list("code", flat=True)), ["first"]
- )
- Product.objects.create(
- organization_id=self.first.id,
- code="created",
- name="Created",
- )
- self.assertEqual(
- Product.objects.filter(code="created").update(name="Updated"), 1
- )
- self.assertEqual(Product.objects.filter(code="created").delete()[0], 1)
-
- with self.assertRaises(DatabaseError), transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- Product.objects.create(
- organization_id=self.second.id,
- code="forged",
- name="Forged",
- )
-
- def test_app_role_updates_only_current_organization(self) -> None:
- with transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- self.assertEqual(
- Organization.objects.filter(id=self.first.id).update(
- name="Updated first"
- ),
- 1,
- )
- self.assertEqual(
- Organization.objects.filter(id=self.second.id).update(
- name="Forged second"
- ),
- 0,
- )
-
- self.first.refresh_from_db()
- self.second.refresh_from_db()
- self.assertEqual(self.first.name, "Updated first")
- self.assertEqual(self.second.name, "Second")
-
- def test_app_role_manages_only_own_storage_reservations(self) -> None:
- with transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- reservation = StorageReservation.objects.create(
- organization=self.first,
- idempotency_key="rls-storage",
- reserved_bytes=128,
- )
- self.assertIsNotNone(reservation.pk)
- self.assertEqual(
- StorageReservation.objects.filter(organization=self.second).count(),
- 0,
- )
-
- with self.assertRaises(DatabaseError), transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- StorageReservation.objects.create(
- organization=self.second,
- idempotency_key="rls-forged-storage",
- reserved_bytes=128,
- )
-
- def test_app_role_writes_platform_audit_event_without_tenant_context(self) -> None:
- # Login-failed audit path: app role, no tenant context, organization NULL.
- # ORM create() issues INSERT ... RETURNING id, which also requires SELECT
- # visibility of the new row (tenancy.0007 policies).
- with transaction.atomic():
- self._set_role("custocrm_runtime_app")
- event = AuditEvent.objects.create(
- action="identity.login_failed",
- object_type="HumanUser",
- result=AuditResult.DENIED,
- correlation_id="rls-null-org-audit",
- )
- self.assertIsNotNone(event.pk)
-
- with transaction.atomic():
- self._set_role("custocrm_runtime_app")
- self.assertEqual(
- AuditEvent.objects.filter(organization__isnull=False).count(), 0
- )
-
- def test_cross_tenant_relation_is_rejected_by_database_trigger(self) -> None:
- with self.assertRaises(DatabaseError), transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- with connection.cursor() as cursor:
- cursor.execute(
- "INSERT INTO identity_employeegroupmember "
- "(organization_id, group_id, employee_id, created_at) "
- "VALUES (%s, %s, %s, NOW())",
- [self.first.id, self.second_group.id, self.membership.id],
- )
-
- def test_cross_tenant_many_to_many_is_rejected(self) -> None:
- channel = Channel.objects.create(
- organization=self.first,
- code="rls-agent",
- name="RLS agent",
- )
- agent = AIAgent.objects.create(
- organization=self.first,
- channel=channel,
- name="RLS agent",
- )
- foreign_knowledge = Knowledge.objects.create(
- organization=self.second,
- category=ensure_uncategorized_category(self.second),
- title="Foreign knowledge",
- )
-
- with self.assertRaises(DatabaseError), transaction.atomic():
- self._set_role("custocrm_runtime_app")
- set_local_tenant(self.first.id)
- with connection.cursor() as cursor:
- cursor.execute(
- "INSERT INTO ai_aiagent_knowledge_items (aiagent_id, knowledge_id) "
- "VALUES (%s, %s)",
- [agent.id, foreign_knowledge.id],
- )
-
- def test_http_request_runs_with_runtime_role_and_tenant_middleware(self) -> None:
- client = TenantAPIClient()
- client.force_authenticate(self.user)
- with connection.cursor() as cursor:
- cursor.execute("SET ROLE custocrm_runtime_app")
- try:
- own = client.get(
- f"/api/v1/organizations/{self.first.public_id}/company/products/"
- )
- foreign = client.get(
- f"/api/v1/organizations/{self.second.public_id}/company/products/"
- )
- finally:
- with connection.cursor() as cursor:
- cursor.execute("RESET ROLE")
- self.assertEqual(own.status_code, 200)
- self.assertEqual([item["code"] for item in own.json()["items"]], ["first"])
- self.assertEqual(foreign.status_code, 404)
-
- def test_platform_role_can_only_use_ingress_directory(self) -> None:
- with self.assertRaises(DatabaseError), transaction.atomic():
- self._set_role("custocrm_runtime_platform")
- with connection.cursor() as cursor:
- cursor.execute("SELECT id FROM identity_product LIMIT 1")
-
- # Ingress-вьюха продаж удалена (ADR-HUB-0041) — используем membership_directory.
- with transaction.atomic():
- self._set_role("custocrm_runtime_platform")
- with connection.cursor() as cursor:
- cursor.execute(
- "SELECT organization_id, resource_id "
- "FROM custocrm.membership_directory WHERE user_id = %s",
- [self.user.id],
- )
- row = cursor.fetchone()
- self.assertIsNotNone(row)
- self.assertEqual(row[0], self.first.id)
-
- def test_transaction_local_context_clears_after_commit_and_rollback(self) -> None:
- with transaction.atomic():
- set_local_tenant(self.first.id)
- self.assertEqual(current_tenant_id(), self.first.id)
- self.assertIsNone(current_tenant_id())
-
- try:
- with transaction.atomic():
- set_local_tenant(self.second.id)
- self.assertEqual(current_tenant_id(), self.second.id)
- raise RuntimeError("rollback")
- except RuntimeError:
- pass
- self.assertIsNone(current_tenant_id())
diff --git a/apps/backend/hub_platform/webchat/apps.py b/apps/backend/hub_platform/webchat/apps.py
deleted file mode 100644
index 0fa3080..0000000
--- a/apps/backend/hub_platform/webchat/apps.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.apps import AppConfig
-
-
-class WebchatConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- label = "webchat"
- name = "hub_platform.webchat"
- verbose_name = "Web chat widget"
diff --git a/apps/backend/manage.py b/apps/backend/manage.py
index 59e4376..3ede56a 100644
--- a/apps/backend/manage.py
+++ b/apps/backend/manage.py
@@ -4,7 +4,7 @@ import sys
def main() -> None:
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatballs_backend.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
diff --git a/apps/backend/pytest.ini b/apps/backend/pytest.ini
index 7b7aa12..b8f33dd 100644
--- a/apps/backend/pytest.ini
+++ b/apps/backend/pytest.ini
@@ -1,3 +1,3 @@
[pytest]
-DJANGO_SETTINGS_MODULE = hub_backend.settings
+DJANGO_SETTINGS_MODULE = chatballs_backend.settings
python_files = tests.py test_*.py *_tests.py
diff --git a/apps/internal-ui/src/api/client.ts b/apps/internal-ui/src/api/client.ts
index f2a573e..ef00633 100644
--- a/apps/internal-ui/src/api/client.ts
+++ b/apps/internal-ui/src/api/client.ts
@@ -2,10 +2,10 @@ const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+
let activeOrganizationPublicId: string | null = null;
// Имя CSRF-cookie должно совпадать с backend CSRF_COOKIE_NAME (settings_app):
-// secure-режим (https) → "__Host-custocrm-app-csrf", dev по http → fallback-имя
+// secure-режим (https) → "__Host-chatballs-app-csrf", dev по http → fallback-имя
// (CSRF_COOKIE_SECURE выключен, префикс __Host- недоступен без Secure).
const CSRF_COOKIE_NAME =
- typeof window !== "undefined" && window.location.protocol === "https:" ? "__Host-custocrm-app-csrf" : "custocrm_app_csrftoken";
+ typeof window !== "undefined" && window.location.protocol === "https:" ? "__Host-chatballs-app-csrf" : "chatballs_app_csrftoken";
const tenantNamespaces = [
"agents",
diff --git a/apps/web-chat/call.html b/apps/web-chat/call.html
index d5cf1c7..3de0142 100644
--- a/apps/web-chat/call.html
+++ b/apps/web-chat/call.html
@@ -4,7 +4,7 @@
- Онлайн-звонок | CustoCRM
+ Онлайн-звонок | Chatballs
diff --git a/custocrm b/chatballs
similarity index 74%
rename from custocrm
rename to chatballs
index 80ff9ff..520ddd9 100644
--- a/custocrm
+++ b/chatballs
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# custocrm — единый deployment entrypoint CustoCRM (ADR-HUB-0028 §CLI).
+# chatballs — единый deployment entrypoint Chatballs (ADR-HUB-0028 §CLI).
# Команды этапа 1: doctor, deploy, status, logs.
# (install/setup-url/update/backup/restore/rollback — этап 2.)
#
@@ -8,11 +8,11 @@
# проектируются в instance через symlink (см. lib/compose.sh).
set -euo pipefail
-CUSTOCRM_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
-: "${CUSTOCRM_RELEASE_DIR:=$CUSTOCRM_SCRIPT_DIR}"
-: "${CUSTOCRM_INSTANCE_DIR:=$PWD}"
-export CUSTOCRM_RELEASE_DIR CUSTOCRM_INSTANCE_DIR
-CLI_LIB_DIR="$CUSTOCRM_RELEASE_DIR/deploy/cli/lib"
+CHATBALLS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+: "${CHATBALLS_RELEASE_DIR:=$CHATBALLS_SCRIPT_DIR}"
+: "${CHATBALLS_INSTANCE_DIR:=$PWD}"
+export CHATBALLS_RELEASE_DIR CHATBALLS_INSTANCE_DIR
+CLI_LIB_DIR="$CHATBALLS_RELEASE_DIR/deploy/cli/lib"
# shellcheck source=deploy/cli/lib/common.sh
. "$CLI_LIB_DIR/common.sh"
@@ -29,9 +29,9 @@ CLI_LIB_DIR="$CUSTOCRM_RELEASE_DIR/deploy/cli/lib"
usage() {
cat >&2 <<'USAGE'
-CustoCRM deployment CLI (stage 1).
+Chatballs deployment CLI (stage 1).
-Usage: custocrm [options]
+Usage: chatballs [options]
Commands:
doctor Pre-flight checks (no container starts, no data changes).
@@ -44,8 +44,8 @@ Global options:
--json Machine-readable output (status/doctor errors).
Environment:
- CUSTOCRM_RELEASE_DIR Release bundle dir (default: script dir).
- CUSTOCRM_INSTANCE_DIR Instance dir with .env and data/ (default: $PWD).
+ CHATBALLS_RELEASE_DIR Release bundle dir (default: script dir).
+ CHATBALLS_INSTANCE_DIR Instance dir with .env and data/ (default: $PWD).
USAGE
}
@@ -57,8 +57,8 @@ shift || true
rest=()
while [[ $# -gt 0 ]]; do
case "$1" in
- --non-interactive) CUSTOCRM_NON_INTERACTIVE=1; shift ;;
- --json) CUSTOCRM_JSON=1; shift ;;
+ --non-interactive) CHATBALLS_NON_INTERACTIVE=1; shift ;;
+ --json) CHATBALLS_JSON=1; shift ;;
*) rest+=("$1"); shift ;;
esac
done
diff --git a/compose.dev.yaml b/compose.dev.yaml
index d3687ac..004e817 100644
--- a/compose.dev.yaml
+++ b/compose.dev.yaml
@@ -8,8 +8,6 @@ services:
postgres:
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
redis:
ports:
@@ -21,26 +19,26 @@ services:
context: .
dockerfile: apps/backend/Dockerfile
environment:
- CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
- CUS_HELP_BASE_DOMAIN: localhost
- CUS_HELP_PUBLIC_SCHEME: http
- CUS_HELP_PUBLIC_PORT: ""
+ CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
+ CHATBALLS_HELP_BASE_DOMAIN: localhost
+ CHATBALLS_HELP_PUBLIC_SCHEME: http
+ CHATBALLS_HELP_PUBLIC_PORT: ""
backend-app:
build:
context: .
dockerfile: apps/backend/Dockerfile
- # Миграции выполняет one-shot init (CUS_DB_ROLE=migration); runtime-роль app
+ # Миграции выполняет one-shot init (CHATBALLS_DB_ROLE=migration); runtime-роль app
# не имеет прав на django_migrations, поэтому migrate здесь запускать нельзя.
- command: uvicorn hub_backend.asgi_app:application --host 0.0.0.0 --port 8000 --reload
+ command: uvicorn chatballs_backend.asgi_app:application --host 0.0.0.0 --port 8000 --reload
ports:
- "${BACKEND_APP_PORT:-8010}:8000"
environment:
- CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
- CUS_ALLOWED_HOSTS: "localhost,127.0.0.1,.localhost"
- CUS_HELP_BASE_DOMAIN: localhost
- CUS_HELP_PUBLIC_SCHEME: http
- CUS_HELP_PUBLIC_PORT: ""
+ CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
+ CHATBALLS_ALLOWED_HOSTS: "localhost,127.0.0.1,.localhost"
+ CHATBALLS_HELP_BASE_DOMAIN: localhost
+ CHATBALLS_HELP_PUBLIC_SCHEME: http
+ CHATBALLS_HELP_PUBLIC_PORT: ""
volumes:
- ./apps/backend:/app/apps/backend
depends_on:
@@ -55,15 +53,15 @@ services:
build:
context: .
dockerfile: apps/backend/Dockerfile
- command: uvicorn hub_backend.asgi_platform:application --host 0.0.0.0 --port 8000 --reload
+ command: uvicorn chatballs_backend.asgi_platform:application --host 0.0.0.0 --port 8000 --reload
ports:
- "${BACKEND_PLATFORM_PORT:-8011}:8000"
environment:
- CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
- CUS_ALLOWED_HOSTS: "localhost,127.0.0.1,.localhost"
- CUS_HELP_BASE_DOMAIN: localhost
- CUS_HELP_PUBLIC_SCHEME: http
- CUS_HELP_PUBLIC_PORT: ""
+ CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
+ CHATBALLS_ALLOWED_HOSTS: "localhost,127.0.0.1,.localhost"
+ CHATBALLS_HELP_BASE_DOMAIN: localhost
+ CHATBALLS_HELP_PUBLIC_SCHEME: http
+ CHATBALLS_HELP_PUBLIC_PORT: ""
volumes:
- ./apps/backend:/app/apps/backend
@@ -71,9 +69,9 @@ services:
build:
context: .
dockerfile: apps/backend/Dockerfile
- command: uvicorn hub_backend.asgi_admin:application --host 0.0.0.0 --port 8000 --reload
+ command: uvicorn chatballs_backend.asgi_admin:application --host 0.0.0.0 --port 8000 --reload
environment:
- CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
+ CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
volumes:
- ./apps/backend:/app/apps/backend
@@ -82,7 +80,7 @@ services:
context: .
dockerfile: apps/backend/Dockerfile
environment:
- CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
+ CHATBALLS_DELIVERY_MODE: ${CHATBALLS_DELIVERY_MODE:-CLOUD}
volumes:
- ./apps/backend:/app/apps/backend
@@ -136,5 +134,3 @@ services:
web-chat:
condition: service_started
-volumes:
- postgres_data:
diff --git a/compose.yaml b/compose.yaml
index 803c615..181a82a 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,4 +1,4 @@
-# Канонический production-манифест CustoCRM (ADR-HUB-0028).
+# Канонический production-манифест Chatballs (ADR-HUB-0028).
# Один Compose для: box-установки клиента, hub.edevs.tech, staging, smoke.
# Локальная разработка — через override compose.dev.yaml; этот файл не содержит
# dev source mounts, dev-команд и host-портов (кроме public 80/443 у gateway).
@@ -14,7 +14,7 @@
services:
postgres:
- image: ${CUSTOCRM_POSTGRES_IMAGE:-pgvector/pgvector:pg16}
+ image: ${CHATBALLS_POSTGRES_IMAGE:-pgvector/pgvector:pg16}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
@@ -27,9 +27,9 @@ services:
POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:?POSTGRES_MIGRATION_USER is required}
POSTGRES_MIGRATION_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
volumes:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data
- - ${CUSTOCRM_RELEASE_DIR:-.}/deploy/postgres/init-runtime-roles.sh:/docker-entrypoint-initdb.d/20-custocrm-runtime-roles.sh:ro
- - ${CUSTOCRM_RELEASE_DIR:-.}/deploy/postgres/reassign-schema-ownership.sql:/custocrm-reassign-ownership.sql:ro
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data
+ - ${CHATBALLS_RELEASE_DIR:-.}/deploy/postgres/init-runtime-roles.sh:/docker-entrypoint-initdb.d/20-chatballs-runtime-roles.sh:ro
+ - ${CHATBALLS_RELEASE_DIR:-.}/deploy/postgres/reassign-schema-ownership.sql:/chatballs-reassign-ownership.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
@@ -37,11 +37,11 @@ services:
retries: 10
redis:
- image: ${CUSTOCRM_REDIS_IMAGE:-redis:7-alpine}
+ image: ${CHATBALLS_REDIS_IMAGE:-redis:7-alpine}
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/redis:/data
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
@@ -53,12 +53,12 @@ services:
# демо-данные создаёт мастер первого запуска в браузере (/api/v1/setup/).
# collectstatic испечён в backend-образ, здесь не вызывается.
init:
- image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
+ image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
env_file:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/.env
+ - ${CHATBALLS_INSTANCE_DIR:-.}/.env
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
- CUS_DB_ROLE: migration
+ CHATBALLS_DB_ROLE: migration
restart: "no"
depends_on:
postgres:
@@ -67,22 +67,22 @@ services:
condition: service_healthy
backend-app:
- image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
+ image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
restart: unless-stopped
env_file:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/.env
+ - ${CHATBALLS_INSTANCE_DIR:-.}/.env
command: >
- sh -c "gunicorn hub_backend.asgi_app:application
+ sh -c "gunicorn chatballs_backend.asgi_app:application
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
- --workers $${CUS_GUNICORN_WORKERS:-3}
- --timeout $${CUS_GUNICORN_TIMEOUT:-60}"
+ --workers $${CHATBALLS_GUNICORN_WORKERS:-3}
+ --timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
environment:
- CUS_DB_ROLE: app
+ CHATBALLS_DB_ROLE: app
volumes:
# Legacy source media retained for the separately approved copy/hash
# migration. Production writes use the required S3 backend in C04.
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media
depends_on:
init:
condition: service_completed_successfully
@@ -96,7 +96,7 @@ services:
"CMD",
"python",
"-c",
- "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CUSTOCRM_APP_HEALTHCHECK_HOST', 'app.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
+ "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_APP_HEALTHCHECK_HOST', 'app.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
]
interval: 10s
timeout: 5s
@@ -104,18 +104,18 @@ services:
start_period: 20s
backend-platform:
- image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
+ image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
restart: unless-stopped
env_file:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/.env
+ - ${CHATBALLS_INSTANCE_DIR:-.}/.env
command: >
- sh -c "gunicorn hub_backend.asgi_platform:application
+ sh -c "gunicorn chatballs_backend.asgi_platform:application
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
- --workers $${CUS_GUNICORN_WORKERS:-3}
- --timeout $${CUS_GUNICORN_TIMEOUT:-60}"
+ --workers $${CHATBALLS_GUNICORN_WORKERS:-3}
+ --timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
environment:
- CUS_DB_ROLE: platform
+ CHATBALLS_DB_ROLE: platform
depends_on:
init:
condition: service_completed_successfully
@@ -129,7 +129,7 @@ services:
"CMD",
"python",
"-c",
- "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CUSTOCRM_PLATFORM_HEALTHCHECK_HOST', 'platform.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
+ "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_PLATFORM_HEALTHCHECK_HOST', 'platform.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
]
interval: 10s
timeout: 5s
@@ -139,22 +139,22 @@ services:
# Django admin не подключён к gateway. Единственный host bind — loopback;
# удалённый доступ допускается только через SSH tunnel (ADR-HUB-0031).
backend-admin:
- image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
+ image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
restart: unless-stopped
env_file:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/.env
+ - ${CHATBALLS_INSTANCE_DIR:-.}/.env
command: >
- sh -c "gunicorn hub_backend.asgi_admin:application
+ sh -c "gunicorn chatballs_backend.asgi_admin:application
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
- --workers $${CUS_GUNICORN_WORKERS:-2}
- --timeout $${CUS_GUNICORN_TIMEOUT:-60}"
+ --workers $${CHATBALLS_GUNICORN_WORKERS:-2}
+ --timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
# Break-glass technical surface only: schema credentials are never used by
# public app/platform runtimes and this service remains loopback-only.
environment:
- CUS_DB_ROLE: migration
+ CHATBALLS_DB_ROLE: migration
ports:
- - "127.0.0.1:${CUSTOCRM_ADMIN_PORT:-18001}:8000"
+ - "127.0.0.1:${CHATBALLS_ADMIN_PORT:-18001}:8000"
depends_on:
init:
condition: service_completed_successfully
@@ -164,15 +164,15 @@ services:
condition: service_healthy
worker:
- image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
+ image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
restart: unless-stopped
env_file:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/.env
+ - ${CHATBALLS_INSTANCE_DIR:-.}/.env
command: ["python", "manage.py", "run_worker"]
environment:
- CUS_DB_ROLE: app
+ CHATBALLS_DB_ROLE: app
volumes:
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media
depends_on:
backend-app:
condition: service_healthy
@@ -182,7 +182,7 @@ services:
# /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует
# host-порт: public boundary — gateway (Caddy).
frontend:
- image: ${CUSTOCRM_FRONTEND_IMAGE:-custocrm-frontend:dev}
+ image: ${CHATBALLS_FRONTEND_IMAGE:-chatballs-frontend:dev}
restart: unless-stopped
depends_on:
backend-app:
@@ -193,19 +193,19 @@ services:
# Маршрутизация публичных путей делегируется frontend (internal). Coturn не
# проксируется через Caddy — отдельная network boundary (profile calls).
gateway:
- image: ${CUSTOCRM_GATEWAY_IMAGE:-caddy:2.8.4}
+ image: ${CHATBALLS_GATEWAY_IMAGE:-caddy:2.8.4}
restart: unless-stopped
environment:
- CUSTOCRM_APP_DOMAIN: ${CUSTOCRM_APP_DOMAIN:?CUSTOCRM_APP_DOMAIN is required}
- CUSTOCRM_PLATFORM_DOMAIN: ${CUSTOCRM_PLATFORM_DOMAIN:?CUSTOCRM_PLATFORM_DOMAIN is required}
- CUSTOCRM_ACME_EMAIL: ${CUSTOCRM_ACME_EMAIL:?CUSTOCRM_ACME_EMAIL is required}
+ CHATBALLS_APP_DOMAIN: ${CHATBALLS_APP_DOMAIN:?CHATBALLS_APP_DOMAIN is required}
+ CHATBALLS_PLATFORM_DOMAIN: ${CHATBALLS_PLATFORM_DOMAIN:?CHATBALLS_PLATFORM_DOMAIN is required}
+ CHATBALLS_ACME_EMAIL: ${CHATBALLS_ACME_EMAIL:?CHATBALLS_ACME_EMAIL is required}
ports:
- - "${CUSTOCRM_WEB_LISTENING_IP:-0.0.0.0}:80:80"
- - "${CUSTOCRM_WEB_LISTENING_IP:-0.0.0.0}:443:443"
+ - "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:80:80"
+ - "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:443:443"
volumes:
- - ${CUSTOCRM_RELEASE_DIR:-.}/Caddyfile:/etc/caddy/Caddyfile:ro
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/caddy:/data
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/caddy-config:/config
+ - ${CHATBALLS_RELEASE_DIR:-.}/Caddyfile:/etc/caddy/Caddyfile:ro
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/caddy:/data
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/caddy-config:/config
depends_on:
frontend:
condition: service_started
@@ -218,10 +218,10 @@ services:
# Опциональный profile `calls` (ADR-HUB-0028): включается через COMPOSE_PROFILES=calls.
# Host networking: relay использует широкий UDP-диапазон и реальный внешний IP,
# публикуется напрямую и не проходит через HTTP reverse proxy. credentials
- # выдаёт backend по общему CUS_CALL_TURN_SECRET (static-auth-secret).
+ # выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET (static-auth-secret).
coturn:
profiles: ["calls"]
- image: ${CUSTOCRM_COTURN_IMAGE:-coturn/coturn:4.6}
+ image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6}
restart: unless-stopped
network_mode: host
command:
@@ -230,24 +230,24 @@ services:
- --no-cli
- --fingerprint
- --use-auth-secret
- - --static-auth-secret=${CUS_CALL_TURN_SECRET}
- - --realm=${CUS_CALL_TURN_REALM}
+ - --static-auth-secret=${CHATBALLS_CALL_TURN_SECRET}
+ - --realm=${CHATBALLS_CALL_TURN_REALM}
# Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на
# основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web.
- - --listening-ip=${CUS_TURN_LISTENING_IP}
- - --relay-ip=${CUS_TURN_EXTERNAL_IP}
- - --external-ip=${CUS_TURN_EXTERNAL_IP}
- - --listening-port=${CUS_TURN_LISTENING_PORT:-3478}
+ - --listening-ip=${CHATBALLS_TURN_LISTENING_IP}
+ - --relay-ip=${CHATBALLS_TURN_EXTERNAL_IP}
+ - --external-ip=${CHATBALLS_TURN_EXTERNAL_IP}
+ - --listening-port=${CHATBALLS_TURN_LISTENING_PORT:-3478}
# TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут.
- - --tls-listening-port=${CUS_TURN_TLS_PORT:-443}
+ - --tls-listening-port=${CHATBALLS_TURN_TLS_PORT:-443}
- --cert=/etc/coturn/certs/fullchain.pem
- --pkey=/etc/coturn/certs/privkey.pem
- - --min-port=${CUS_TURN_MIN_PORT:-49160}
+ - --min-port=${CHATBALLS_TURN_MIN_PORT:-49160}
# Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок).
# 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте.
- # Расширяется через CUS_TURN_MIN_PORT/CUS_TURN_MAX_PORT; все порты диапазона
+ # Расширяется через CHATBALLS_TURN_MIN_PORT/CHATBALLS_TURN_MAX_PORT; все порты диапазона
# должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС.
- - --max-port=${CUS_TURN_MAX_PORT:-49999}
+ - --max-port=${CHATBALLS_TURN_MAX_PORT:-49999}
# Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом).
- --no-multicast-peers
- --no-tcp-relay
@@ -259,10 +259,10 @@ services:
volumes:
# LE-сертификат TURN-хоста (turns:), скопированный под uid coturn (nobody)
# renewal deploy-hook'ом. См. docs по развёртыванию TURN-over-TLS.
- - ${CUSTOCRM_INSTANCE_DIR:-.}/data/coturn-certs:/etc/coturn/certs:ro
+ - ${CHATBALLS_INSTANCE_DIR:-.}/data/coturn-certs:/etc/coturn/certs:ro
healthcheck:
# Allocation smoke: STUN binding к собственному listener на выделенном IP.
- test: ["CMD", "turnutils_stunclient", "-p", "${CUS_TURN_LISTENING_PORT:-3478}", "${CUS_TURN_LISTENING_IP}"]
+ test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP}"]
interval: 30s
timeout: 5s
retries: 5
diff --git a/deploy/cli/lib/common.sh b/deploy/cli/lib/common.sh
index 8dd9657..71d81a2 100644
--- a/deploy/cli/lib/common.sh
+++ b/deploy/cli/lib/common.sh
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
-# common.sh — shared helpers for the CustoCRM deployment CLI.
+# common.sh — shared helpers for the Chatballs deployment CLI.
-CUSTOCRM_NON_INTERACTIVE=0
-CUSTOCRM_JSON=0
+CHATBALLS_NON_INTERACTIVE=0
+CHATBALLS_JSON=0
-log() { printf '[custocrm] %s\n' "$*" >&2; }
-log_ok() { printf '[custocrm] OK: %s\n' "$*" >&2; }
-log_warn() { printf '[custocrm] WARN: %s\n' "$*" >&2; }
-log_err() { printf '[custocrm] ERROR: %s\n' "$*" >&2; }
+log() { printf '[chatballs] %s\n' "$*" >&2; }
+log_ok() { printf '[chatballs] OK: %s\n' "$*" >&2; }
+log_warn() { printf '[chatballs] WARN: %s\n' "$*" >&2; }
+log_err() { printf '[chatballs] ERROR: %s\n' "$*" >&2; }
die() {
local msg="$1"
local code="${2:-1}"
- if [[ "$CUSTOCRM_JSON" == "1" ]]; then
+ if [[ "$CHATBALLS_JSON" == "1" ]]; then
printf '{"status":"error","error":%s}\n' "$(json_escape "$msg")"
else
log_err "$msg"
@@ -35,11 +35,11 @@ require_cmd() {
}
release_dir() {
- printf '%s' "${CUSTOCRM_RELEASE_DIR:?CUSTOCRM_RELEASE_DIR is not set}"
+ printf '%s' "${CHATBALLS_RELEASE_DIR:?CHATBALLS_RELEASE_DIR is not set}"
}
instance_dir() {
- printf '%s' "${CUSTOCRM_INSTANCE_DIR:?CUSTOCRM_INSTANCE_DIR is not set}"
+ printf '%s' "${CHATBALLS_INSTANCE_DIR:?CHATBALLS_INSTANCE_DIR is not set}"
}
instance_env_file() { printf '%s/.env' "$(instance_dir)"; }
@@ -100,15 +100,15 @@ verify_release_checksums() {
validate_calls_network_boundary() {
local web_ip turn_ip
- web_ip="$(env_get "$(instance_env_file)" CUSTOCRM_WEB_LISTENING_IP)"
- turn_ip="$(env_get "$(instance_env_file)" CUS_TURN_LISTENING_IP)"
+ web_ip="$(env_get "$(instance_env_file)" CHATBALLS_WEB_LISTENING_IP)"
+ turn_ip="$(env_get "$(instance_env_file)" CHATBALLS_TURN_LISTENING_IP)"
[[ -n "$web_ip" ]] || {
- log_err "CUSTOCRM_WEB_LISTENING_IP is required for calls profile"
+ log_err "CHATBALLS_WEB_LISTENING_IP is required for calls profile"
return 1
}
[[ "$web_ip" != "0.0.0.0" ]] || {
- log_err "CUSTOCRM_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443"
+ log_err "CHATBALLS_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443"
return 1
}
[[ "$web_ip" != "$turn_ip" ]] || {
@@ -119,12 +119,12 @@ validate_calls_network_boundary() {
release_image_keys() {
printf '%s\n' \
- CUSTOCRM_BACKEND_IMAGE \
- CUSTOCRM_FRONTEND_IMAGE \
- CUSTOCRM_POSTGRES_IMAGE \
- CUSTOCRM_REDIS_IMAGE \
- CUSTOCRM_GATEWAY_IMAGE \
- CUSTOCRM_COTURN_IMAGE
+ CHATBALLS_BACKEND_IMAGE \
+ CHATBALLS_FRONTEND_IMAGE \
+ CHATBALLS_POSTGRES_IMAGE \
+ CHATBALLS_REDIS_IMAGE \
+ CHATBALLS_GATEWAY_IMAGE \
+ CHATBALLS_COTURN_IMAGE
}
validate_release_image_refs() {
diff --git a/deploy/cli/lib/deploy.sh b/deploy/cli/lib/deploy.sh
index bf65570..c5362b3 100644
--- a/deploy/cli/lib/deploy.sh
+++ b/deploy/cli/lib/deploy.sh
@@ -56,10 +56,10 @@ _deploy_validate() {
validate_release_image_refs || return 1
local app_domain platform_domain
- app_domain="$(env_get "$(instance_env_file)" CUSTOCRM_APP_DOMAIN)"
- platform_domain="$(env_get "$(instance_env_file)" CUSTOCRM_PLATFORM_DOMAIN)"
- [[ -n "$app_domain" ]] || { log_err "CUSTOCRM_APP_DOMAIN not set"; return 1; }
- [[ -n "$platform_domain" ]] || { log_err "CUSTOCRM_PLATFORM_DOMAIN not set"; return 1; }
+ app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
+ platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
+ [[ -n "$app_domain" ]] || { log_err "CHATBALLS_APP_DOMAIN not set"; return 1; }
+ [[ -n "$platform_domain" ]] || { log_err "CHATBALLS_PLATFORM_DOMAIN not set"; return 1; }
[[ "$app_domain" != "$platform_domain" ]] || {
log_err "app and platform domains must be distinct"
return 1
@@ -100,7 +100,7 @@ _wait_running() {
}
_normalize_schema_ownership() {
- # Приводит владение объектов public-схемы к роли custocrm_schema, в которую
+ # Приводит владение объектов public-схемы к роли chatballs_schema, в которую
# входит migration-user. Идемпотентно: безопасно на каждом деплое. Без этого
# миграции от migration-user падают на таблицах, созданных не им
# («must be owner of table …»). Выполняется под суперпользователем POSTGRES_USER.
@@ -112,7 +112,7 @@ _normalize_schema_ownership() {
[[ -n "$pg_db" ]] || { log_err "POSTGRES_DB not set"; return 1; }
run_compose exec -T postgres \
psql -v ON_ERROR_STOP=1 -U "$pg_user" -d "$pg_db" \
- -f /custocrm-reassign-ownership.sql >/dev/null
+ -f /chatballs-reassign-ownership.sql >/dev/null
}
_first_json_service_state() {
@@ -129,18 +129,18 @@ _first_json_service_state() {
_smoke() {
local app_domain platform_domain
- app_domain="$(env_get "$(instance_env_file)" CUSTOCRM_APP_DOMAIN)"
- platform_domain="$(env_get "$(instance_env_file)" CUSTOCRM_PLATFORM_DOMAIN)"
+ app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
+ platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
run_compose exec -T backend-app python - <<'PY' >/dev/null 2>&1 || {
import os
import urllib.error
import urllib.request
-app_domain = os.environ["CUSTOCRM_APP_DOMAIN"]
-platform_domain = os.environ["CUSTOCRM_PLATFORM_DOMAIN"]
-app_health_host = os.environ.get("CUSTOCRM_APP_HEALTHCHECK_HOST") or app_domain
-platform_health_host = os.environ.get("CUSTOCRM_PLATFORM_HEALTHCHECK_HOST") or platform_domain
+app_domain = os.environ["CHATBALLS_APP_DOMAIN"]
+platform_domain = os.environ["CHATBALLS_PLATFORM_DOMAIN"]
+app_health_host = os.environ.get("CHATBALLS_APP_HEALTHCHECK_HOST") or app_domain
+platform_health_host = os.environ.get("CHATBALLS_PLATFORM_HEALTHCHECK_HOST") or platform_domain
app_health_request = urllib.request.Request(
"http://127.0.0.1:8000/api/v1/health/ready/",
headers={"Host": app_health_host, "X-Forwarded-Proto": "https"},
@@ -194,11 +194,11 @@ _state_file() { printf '%s/applied_release' "$(state_dir)"; }
_record_release() {
local ver
- ver="$(env_get "$(release_env_file)" CUSTOCRM_VERSION)"
+ ver="$(env_get "$(release_env_file)" CHATBALLS_VERSION)"
printf 'applied_version=%s\napplied_at=%s\n' "${ver:-unknown}" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" > "$(_state_file)"
}
_applied_version_target() {
- env_get "$(release_env_file)" CUSTOCRM_VERSION || printf 'unknown'
+ env_get "$(release_env_file)" CHATBALLS_VERSION || printf 'unknown'
}
diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh
index c474240..208c4d0 100644
--- a/deploy/cli/lib/doctor.sh
+++ b/deploy/cli/lib/doctor.sh
@@ -11,9 +11,9 @@ cmd_doctor() {
shift
local msg="$*"
if [[ "$ok" == "1" ]]; then
- [[ "$CUSTOCRM_JSON" == "1" ]] || log_ok "$msg"
+ [[ "$CHATBALLS_JSON" == "1" ]] || log_ok "$msg"
else
- [[ "$CUSTOCRM_JSON" == "1" ]] || log_err "$msg"
+ [[ "$CHATBALLS_JSON" == "1" ]] || log_err "$msg"
failures=$((failures + 1))
fi
}
@@ -86,17 +86,17 @@ cmd_doctor() {
fi
local app_domain platform_domain
- app_domain="$(env_get "$(instance_env_file)" CUSTOCRM_APP_DOMAIN)"
- platform_domain="$(env_get "$(instance_env_file)" CUSTOCRM_PLATFORM_DOMAIN)"
+ app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
+ platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
if [[ -n "$app_domain" ]]; then
- _doctor_report 1 "CUSTOCRM_APP_DOMAIN set: $app_domain"
+ _doctor_report 1 "CHATBALLS_APP_DOMAIN set: $app_domain"
else
- _doctor_report 0 "CUSTOCRM_APP_DOMAIN not set"
+ _doctor_report 0 "CHATBALLS_APP_DOMAIN not set"
fi
if [[ -n "$platform_domain" ]]; then
- _doctor_report 1 "CUSTOCRM_PLATFORM_DOMAIN set: $platform_domain"
+ _doctor_report 1 "CHATBALLS_PLATFORM_DOMAIN set: $platform_domain"
else
- _doctor_report 0 "CUSTOCRM_PLATFORM_DOMAIN not set"
+ _doctor_report 0 "CHATBALLS_PLATFORM_DOMAIN not set"
fi
if [[ -n "$app_domain" ]] && [[ "$app_domain" != "$platform_domain" ]]; then
_doctor_report 1 "app and platform domains are distinct"
@@ -105,11 +105,11 @@ cmd_doctor() {
fi
local acme_email
- acme_email="$(env_get "$(instance_env_file)" CUSTOCRM_ACME_EMAIL)"
+ acme_email="$(env_get "$(instance_env_file)" CHATBALLS_ACME_EMAIL)"
if [[ -n "$acme_email" ]]; then
- _doctor_report 1 "CUSTOCRM_ACME_EMAIL set"
+ _doctor_report 1 "CHATBALLS_ACME_EMAIL set"
else
- _doctor_report 0 "CUSTOCRM_ACME_EMAIL not set"
+ _doctor_report 0 "CHATBALLS_ACME_EMAIL not set"
fi
local pg_pwd
@@ -121,16 +121,16 @@ cmd_doctor() {
fi
local secret
- secret="$(env_get "$(instance_env_file)" CUS_SECRET_KEY)"
+ secret="$(env_get "$(instance_env_file)" CHATBALLS_SECRET_KEY)"
if [[ -n "$secret" ]] && [[ "$secret" != "change-me-long-random-secret" ]]; then
- _doctor_report 1 "CUS_SECRET_KEY set"
+ _doctor_report 1 "CHATBALLS_SECRET_KEY set"
else
- _doctor_report 0 "CUS_SECRET_KEY default/empty"
+ _doctor_report 0 "CHATBALLS_SECRET_KEY default/empty"
fi
if profile_enabled calls; then
local missing=0 k
- for k in CUS_CALL_TURN_SECRET CUS_CALL_TURN_REALM CUS_TURN_EXTERNAL_IP CUS_TURN_LISTENING_IP; do
+ for k in CHATBALLS_CALL_TURN_SECRET CHATBALLS_CALL_TURN_REALM CHATBALLS_TURN_EXTERNAL_IP CHATBALLS_TURN_LISTENING_IP; do
if [[ -z "$(env_get "$(instance_env_file)" "$k")" ]]; then
_doctor_report 0 "$k required for calls profile"
missing=1
@@ -159,7 +159,7 @@ cmd_doctor() {
die "doctor: $failures check(s) failed" 1
fi
- if [[ "$CUSTOCRM_JSON" == "1" ]]; then
+ if [[ "$CHATBALLS_JSON" == "1" ]]; then
printf '{"status":"ok","checks":"passed"}\n'
else
log_ok "doctor: all checks passed"
diff --git a/deploy/cli/lib/status.sh b/deploy/cli/lib/status.sh
index 8767586..b6b2305 100644
--- a/deploy/cli/lib/status.sh
+++ b/deploy/cli/lib/status.sh
@@ -6,17 +6,17 @@ cmd_status() {
local inst rel applied target
inst="$(instance_dir)"
rel="$(release_dir)"
- target="$(env_get "$(release_env_file)" CUSTOCRM_VERSION)"
+ target="$(env_get "$(release_env_file)" CHATBALLS_VERSION)"
applied="$(env_get "$(_state_file)" applied_version)"
- if [[ "$CUSTOCRM_JSON" == "1" ]]; then
+ if [[ "$CHATBALLS_JSON" == "1" ]]; then
printf '{"status":"info","instance":%s,"release_dir":%s,"target_version":%s,"applied_version":%s}\n' \
"$(json_escape "$inst")" "$(json_escape "$rel")" \
"$(json_escape "${target:-unknown}")" "$(json_escape "${applied:-none}")"
return 0
fi
- printf 'CustoCRM status\n'
+ printf 'Chatballs status\n'
printf ' instance dir: %s\n' "$inst"
printf ' release dir: %s\n' "$rel"
printf ' target version: %s\n' "${target:-unknown}"
diff --git a/deploy/migrate/rename-to-chatballs.sh b/deploy/migrate/rename-to-chatballs.sh
new file mode 100644
index 0000000..cbe4c18
--- /dev/null
+++ b/deploy/migrate/rename-to-chatballs.sh
@@ -0,0 +1,163 @@
+#!/usr/bin/env bash
+# Миграция существующей установки CustoCRM/hub → Chatballs (2026-09-05).
+#
+# Что переименовывается на работающей БД (данные не трогаются):
+# • роли Postgres: custocrm_bootstrap/app/platform/migration → chatballs_*,
+# групповые custocrm_runtime_app / custocrm_runtime_platform / custocrm_schema → chatballs_*;
+# • схема custocrm (функции RLS, directory-view) → chatballs; GUC custocrm.organization_id → chatballs.organization_id;
+# • база edevs_hub → chatballs;
+# • .env: ключи CUS_* и CUSTOCRM_* → CHATBALLS_*, COMPOSE_PROJECT_NAME, POSTGRES_DB, имена ролей;
+# • compose-проект: старые контейнеры/сеть удаляются, данные в bind-mount ${INSTANCE_DIR}/data остаются на месте.
+#
+# Запуск из каталога релиза (где compose.yaml), инстанс — каталог с .env и data/:
+# CHATBALLS_INSTANCE_DIR=/opt/chatballs/instance deploy/migrate/rename-to-chatballs.sh
+# Для dev-стека: CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml --env-file .env.example" …
+#
+# На проде перед запуском: mv /opt/custocrm /opt/chatballs (каталог релизов и инстанса).
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+RELEASE_DIR="${CHATBALLS_RELEASE_DIR:-${CUSTOCRM_RELEASE_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}}"
+INSTANCE_DIR="${CHATBALLS_INSTANCE_DIR:-${CUSTOCRM_INSTANCE_DIR:-$PWD}}"
+ENV_FILE="$INSTANCE_DIR/.env"
+COMPOSE_ARGS="${CHATBALLS_COMPOSE_ARGS:-}"
+
+log() { printf '[chatballs migrate] %s\n' "$*" >&2; }
+die() { printf '[chatballs migrate] ERROR: %s\n' "$*" >&2; exit 1; }
+env_get() { grep -E "^$1=" "$ENV_FILE" | head -n1 | cut -d= -f2- || true; }
+
+[[ -f "$ENV_FILE" ]] || die ".env не найден: $ENV_FILE"
+[[ -f "$RELEASE_DIR/compose.yaml" ]] || die "compose.yaml не найден в $RELEASE_DIR"
+
+# --- 1. Старые значения ------------------------------------------------------
+OLD_PROJECT="$(env_get COMPOSE_PROJECT_NAME)"; OLD_PROJECT="${OLD_PROJECT:-edevs_hub}"
+OLD_DB="$(env_get POSTGRES_DB)"; OLD_DB="${OLD_DB:-edevs_hub}"
+OLD_SUPER="$(env_get POSTGRES_USER)"; OLD_SUPER="${OLD_SUPER:-custocrm_bootstrap}"
+SUPER_PW="$(env_get POSTGRES_PASSWORD)"
+OLD_APP="$(env_get POSTGRES_APP_USER)"; APP_PW="$(env_get POSTGRES_APP_PASSWORD)"
+OLD_PLATFORM="$(env_get POSTGRES_PLATFORM_USER)"; PLATFORM_PW="$(env_get POSTGRES_PLATFORM_PASSWORD)"
+OLD_MIGRATION="$(env_get POSTGRES_MIGRATION_USER)"; MIGRATION_PW="$(env_get POSTGRES_MIGRATION_PASSWORD)"
+[[ -n "$SUPER_PW" && -n "$APP_PW" && -n "$PLATFORM_PW" && -n "$MIGRATION_PW" ]] || die "в .env нет паролей Postgres"
+
+rename_value() { printf '%s' "$1" | sed -e 's/^custocrm_/chatballs_/'; }
+NEW_SUPER="$(rename_value "$OLD_SUPER")"
+NEW_APP="$(rename_value "$OLD_APP")"
+NEW_PLATFORM="$(rename_value "$OLD_PLATFORM")"
+NEW_MIGRATION="$(rename_value "$OLD_MIGRATION")"
+NEW_DB="chatballs"; [[ "$OLD_DB" == "edevs_hub" || "$OLD_DB" == "custocrm" ]] || NEW_DB="$OLD_DB"
+
+if grep -qE '^(CUS_|CUSTOCRM_)' "$ENV_FILE" || [[ "$OLD_PROJECT" != "chatballs" ]]; then
+ log "старый проект: $OLD_PROJECT · база: $OLD_DB · суперпользователь: $OLD_SUPER"
+else
+ log ".env уже переименован — повторный запуск"
+fi
+
+# --- 2. Остановить старый стек (bind-mount data/ остаётся) --------------------
+log "останавливаю compose-проект $OLD_PROJECT"
+docker compose -p "$OLD_PROJECT" ls >/dev/null 2>&1 || true
+docker ps -aq --filter "label=com.docker.compose.project=$OLD_PROJECT" | xargs -r docker rm -f >/dev/null
+docker network ls -q --filter "label=com.docker.compose.project=$OLD_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true
+
+# --- 3. Переписать .env --------------------------------------------------------
+cp "$ENV_FILE" "$ENV_FILE.bak-custocrm"
+log "переписываю .env (резервная копия: $ENV_FILE.bak-custocrm)"
+PY_BIN="$(command -v python3 >/dev/null 2>&1 && python3 -c "print(1)" >/dev/null 2>&1 && echo python3 || echo python)"
+"$PY_BIN" - "$ENV_FILE" "$NEW_DB" "$NEW_SUPER" "$NEW_APP" "$NEW_PLATFORM" "$NEW_MIGRATION" <<'PY'
+import re, sys
+path, new_db, new_super, new_app, new_platform, new_migration = sys.argv[1:]
+values = {"COMPOSE_PROJECT_NAME": "chatballs", "POSTGRES_DB": new_db, "POSTGRES_USER": new_super,
+ "POSTGRES_APP_USER": new_app, "POSTGRES_PLATFORM_USER": new_platform, "POSTGRES_MIGRATION_USER": new_migration}
+out = []
+for line in open(path, encoding="utf-8").read().splitlines():
+ line = re.sub(r"^(#\s*)?(CUSTOCRM_|CUS_)", lambda m: (m.group(1) or "") + "CHATBALLS_", line)
+ m = re.match(r"^([A-Z_]+)=", line)
+ if m and m.group(1) in values:
+ line = f"{m.group(1)}={values[m.group(1)]}"
+ out.append(line)
+open(path, "w", encoding="utf-8", newline="\n").write("\n".join(out) + "\n")
+PY
+
+compose() { docker compose -f "$RELEASE_DIR/compose.yaml" $COMPOSE_ARGS --env-file "$ENV_FILE" "$@"; }
+psql_as() { # user db
+ compose exec -T -e PGPASSWORD="$SUPER_PW" postgres psql -v ON_ERROR_STOP=1 -qAt -U "$1" -d "$2"
+}
+
+# --- 5. Поднять только Postgres нового проекта ---------------------------------
+log "поднимаю postgres (проект chatballs)"
+compose up -d postgres >/dev/null
+for _ in $(seq 1 60); do
+ compose exec -T postgres pg_isready -U "$OLD_SUPER" -d postgres >/dev/null 2>&1 && break
+ sleep 1
+done
+compose exec -T postgres pg_isready -U "$OLD_SUPER" -d postgres >/dev/null || die "postgres не поднялся"
+
+role_exists() { psql_as "$1" postgres <<<"SELECT 1 FROM pg_roles WHERE rolname = '$2'" | grep -q 1; }
+rename_role() { # who old new
+ if [[ "$2" != "$3" ]] && role_exists "$1" "$2"; then
+ log "роль $2 → $3"; psql_as "$1" postgres <<<"ALTER ROLE $2 RENAME TO $3;"
+ fi
+}
+
+# --- 6. Суперпользователь initdb владеет системными объектами — его нельзя пересоздать,
+# только переименовать из-под временной роли (свою сессионную роль переименовать нельзя).
+TMP_SUPER="chatballs_migrator_tmp"
+if [[ "$OLD_SUPER" != "$NEW_SUPER" ]] && role_exists "$OLD_SUPER" "$OLD_SUPER"; then
+ log "переименовываю суперпользователя $OLD_SUPER → $NEW_SUPER"
+ psql_as "$OLD_SUPER" postgres < pg_backend_pid();
+ALTER DATABASE $OLD_DB RENAME TO $NEW_DB;
+SQL
+fi
+
+if psql_as "$SUPER" "$NEW_DB" <<<"SELECT 1 FROM pg_namespace WHERE nspname = 'custocrm'" | grep -q 1; then
+ log "схема custocrm → chatballs, функция RLS на новый GUC"
+ psql_as "$SUPER" "$NEW_DB" <<'SQL'
+ALTER SCHEMA custocrm RENAME TO chatballs;
+CREATE OR REPLACE FUNCTION chatballs.current_organization_id() RETURNS bigint
+LANGUAGE sql STABLE PARALLEL SAFE AS $$
+ SELECT CASE
+ WHEN current_setting('chatballs.organization_id', true) ~ '^[1-9][0-9]*$'
+ THEN current_setting('chatballs.organization_id', true)::bigint
+ ELSE NULL
+ END
+$$;
+SQL
+fi
+
+# --- 8. Проверка -----------------------------------------------------------------
+psql_as "$SUPER" "$NEW_DB" <<'SQL' | sed 's/^/[chatballs migrate] /' >&2
+SELECT 'roles: ' || string_agg(rolname, ', ' ORDER BY rolname) FROM pg_roles WHERE rolname LIKE 'chatballs%';
+SELECT 'schema chatballs: ' || count(*) || ' objects' FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'chatballs';
+SELECT 'leftovers custocrm: ' || count(*) FROM pg_roles WHERE rolname LIKE 'custocrm%';
+SQL
+
+log "готово. Дальше: docker compose up (dev) или chatballs deploy (prod)."
+log "Пользователям потребуется войти заново — имена cookie сессии изменились."
diff --git a/deploy/postgres/init-runtime-roles.sh b/deploy/postgres/init-runtime-roles.sh
index 518c0f8..bff9cc6 100644
--- a/deploy/postgres/init-runtime-roles.sh
+++ b/deploy/postgres/init-runtime-roles.sh
@@ -17,14 +17,14 @@ psql --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
--set=migration_password="$POSTGRES_MIGRATION_PASSWORD" <<'SQL'
CREATE EXTENSION IF NOT EXISTS vector;
-SELECT 'CREATE ROLE custocrm_runtime_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS'
-WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_app')
+SELECT 'CREATE ROLE chatballs_runtime_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS'
+WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chatballs_runtime_app')
\gexec
-SELECT 'CREATE ROLE custocrm_runtime_platform NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS'
-WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_runtime_platform')
+SELECT 'CREATE ROLE chatballs_runtime_platform NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS'
+WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chatballs_runtime_platform')
\gexec
-SELECT 'CREATE ROLE custocrm_schema NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS'
-WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custocrm_schema')
+SELECT 'CREATE ROLE chatballs_schema NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS'
+WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chatballs_schema')
\gexec
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', :'app_user', :'app_password')
@@ -37,15 +37,15 @@ SELECT format('CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATER
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'migration_user')
\gexec
-SELECT format('GRANT custocrm_runtime_app TO %I', :'app_user') \gexec
-SELECT format('GRANT custocrm_runtime_platform TO %I', :'platform_user') \gexec
-SELECT format('GRANT custocrm_schema TO %I', :'migration_user') \gexec
--- Runtime roles must not inherit the permissive custocrm_schema_access policy:
+SELECT format('GRANT chatballs_runtime_app TO %I', :'app_user') \gexec
+SELECT format('GRANT chatballs_runtime_platform TO %I', :'platform_user') \gexec
+SELECT format('GRANT chatballs_schema TO %I', :'migration_user') \gexec
+-- Runtime roles must not inherit the permissive chatballs_schema_access policy:
-- membership in the schema-owner role would bypass tenant RLS entirely.
-REVOKE custocrm_schema FROM custocrm_runtime_app, custocrm_runtime_platform;
+REVOKE chatballs_schema FROM chatballs_runtime_app, chatballs_runtime_platform;
SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'app_user') \gexec
SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'platform_user') \gexec
SELECT format('GRANT CONNECT, CREATE, TEMPORARY ON DATABASE %I TO %I', current_database(), :'migration_user') \gexec
SELECT format('GRANT USAGE, CREATE ON SCHEMA public TO %I', :'migration_user') \gexec
-GRANT USAGE, CREATE ON SCHEMA public TO custocrm_schema;
+GRANT USAGE, CREATE ON SCHEMA public TO chatballs_schema;
SQL
diff --git a/deploy/postgres/reassign-schema-ownership.sql b/deploy/postgres/reassign-schema-ownership.sql
index 4a5410b..75d3ea5 100644
--- a/deploy/postgres/reassign-schema-ownership.sql
+++ b/deploy/postgres/reassign-schema-ownership.sql
@@ -1,14 +1,14 @@
-- Нормализация владельца объектов public-схемы под роль миграций.
--
--- Контекст: при раздельных ролях (custocrm_migration / custocrm_app /
--- custocrm_platform) Django-миграции от migration-user требуют, чтобы
+-- Контекст: при раздельных ролях (chatballs_migration / chatballs_app /
+-- chatballs_platform) Django-миграции от migration-user требуют, чтобы
-- выполняющий был ВЛАДЕЛЬЦЕМ изменяемой таблицы («must be owner of table …»).
-- Таблицы, созданные/импортированные иначе (суперпользователем postgres,
-- app-role или до ввода разделения ролей), оказываются «осиротевшими», и
-- AddField/AlterField на них падает в deploy.
--
--- Скрипт переписывает владение всей public-схемы на custocrm_schema — роль,
--- в которую входит custocrm_migration (см. init-runtime-roles.sh). После этого
+-- Скрипт переписывает владение всей public-схемы на chatballs_schema — роль,
+-- в которую входит chatballs_migration (см. init-runtime-roles.sh). После этого
-- любой migration-user может мигрировать любые таблицы.
--
-- Выполнять под суперпользователем postgres (не под app/migration role):
@@ -19,8 +19,8 @@
\set ON_ERROR_STOP on
--- 1. Таблицы, последовательности, функции, типы → custocrm_schema.
-ALTER SCHEMA public OWNER TO custocrm_schema;
+-- 1. Таблицы, последовательности, функции, типы → chatballs_schema.
+ALTER SCHEMA public OWNER TO chatballs_schema;
DO $$
DECLARE
@@ -30,14 +30,14 @@ BEGIN
FOR r IN
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
LOOP
- EXECUTE format('ALTER TABLE public.%I OWNER TO custocrm_schema', r.tablename);
+ EXECUTE format('ALTER TABLE public.%I OWNER TO chatballs_schema', r.tablename);
END LOOP;
-- Последовательности (включая owned-последовательности таблиц).
FOR r IN
SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public'
LOOP
- EXECUTE format('ALTER SEQUENCE public.%I OWNER TO custocrm_schema', r.sequence_name);
+ EXECUTE format('ALTER SEQUENCE public.%I OWNER TO chatballs_schema', r.sequence_name);
END LOOP;
-- Функции/процедуры в public.
@@ -47,7 +47,7 @@ BEGIN
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
LOOP
- EXECUTE format('ALTER FUNCTION %s(%s) OWNER TO custocrm_schema', r.proname, r.args);
+ EXECUTE format('ALTER FUNCTION %s(%s) OWNER TO chatballs_schema', r.proname, r.args);
END LOOP;
END $$;
diff --git a/env.example b/env.example
index 2757428..e79a5d1 100644
--- a/env.example
+++ b/env.example
@@ -1,121 +1,121 @@
-# Instance configuration CustoCRM (ADR-HUB-0028 / SPEC-HUB-0019 §7.2).
+# Instance configuration Chatballs (ADR-HUB-0028 / SPEC-HUB-0019 §7.2).
# Поставляется в release bundle. Копируется в instance/.env и редактируется
# под конкретный экземпляр. Image references НЕ здесь — они в release.env (CI).
# Здесь нет initial OWNER password и нет demo-флага (см. SPEC §7.2 запреты).
-COMPOSE_PROJECT_NAME=custocrm
+COMPOSE_PROJECT_NAME=chatballs
# --- Instance identity / gateway ---
-CUSTOCRM_APP_DOMAIN=app.example.com
-CUSTOCRM_PLATFORM_DOMAIN=platform.example.com
-CUS_HELP_BASE_DOMAIN=help.example.com
-CUS_HELP_PUBLIC_SCHEME=https
-CUS_HELP_PUBLIC_PORT=
+CHATBALLS_APP_DOMAIN=app.example.com
+CHATBALLS_PLATFORM_DOMAIN=platform.example.com
+CHATBALLS_HELP_BASE_DOMAIN=help.example.com
+CHATBALLS_HELP_PUBLIC_SCHEME=https
+CHATBALLS_HELP_PUBLIC_PORT=
# Необязательный отдельный IPv4 для A-записей собственных доменов.
-# По умолчанию используется CUSTOCRM_WEB_LISTENING_IP.
-CUS_HELP_PUBLIC_IPV4=203.0.113.10
-CUSTOCRM_ACME_EMAIL=admin@example.com
-CUSTOCRM_ADMIN_PORT=18001
+# По умолчанию используется CHATBALLS_WEB_LISTENING_IP.
+CHATBALLS_HELP_PUBLIC_IPV4=203.0.113.10
+CHATBALLS_ACME_EMAIL=admin@example.com
+CHATBALLS_ADMIN_PORT=18001
# IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите
-# отдельный публичный IP, отличный от CUS_TURN_LISTENING_IP.
-CUSTOCRM_WEB_LISTENING_IP=0.0.0.0
+# отдельный публичный IP, отличный от CHATBALLS_TURN_LISTENING_IP.
+CHATBALLS_WEB_LISTENING_IP=0.0.0.0
# Опциональные profiles: calls (coturn), и в будущем voice.
# COMPOSE_PROFILES=calls
# --- Django core ---
-CUS_ENV=production
-CUS_DEBUG=false
+CHATBALLS_ENV=production
+CHATBALLS_DEBUG=false
# Коробочная поставка. Облачный контур использует CLOUD.
-CUS_DELIVERY_MODE=SELF_HOSTED
-CUS_SECRET_KEY=change-me-long-random-secret
+CHATBALLS_DELIVERY_MODE=SELF_HOSTED
+CHATBALLS_SECRET_KEY=change-me-long-random-secret
# Шифрование секретов в БД (Fernet-ключ): Fernet.generate_key().
-CUS_FIELD_ENCRYPTION_KEY=
+CHATBALLS_FIELD_ENCRYPTION_KEY=
-CUSTOCRM_APP_ALLOWED_HOSTS=app.example.com
-CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=https://app.example.com
-CUSTOCRM_PLATFORM_ALLOWED_HOSTS=platform.example.com
-CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=https://platform.example.com
-CUS_CORS_ALLOWED_ORIGINS=https://app.example.com
+CHATBALLS_APP_ALLOWED_HOSTS=app.example.com
+CHATBALLS_APP_CSRF_TRUSTED_ORIGINS=https://app.example.com
+CHATBALLS_PLATFORM_ALLOWED_HOSTS=platform.example.com
+CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS=https://platform.example.com
+CHATBALLS_CORS_ALLOWED_ORIGINS=https://app.example.com
INTERNAL_UI_BASE_URL=https://app.example.com
# Host headers для Docker healthchecks должны входить в surface ALLOWED_HOSTS.
-CUSTOCRM_APP_HEALTHCHECK_HOST=app.example.com
-CUSTOCRM_PLATFORM_HEALTHCHECK_HOST=platform.example.com
+CHATBALLS_APP_HEALTHCHECK_HOST=app.example.com
+CHATBALLS_PLATFORM_HEALTHCHECK_HOST=platform.example.com
-# Транспортная безопасность (вне CUS_DEBUG включается автоматически).
-CUS_COOKIE_SECURE=true
-CUS_SSL_REDIRECT=true
-CUS_HSTS_SECONDS=31536000
-CUS_COOKIE_SAMESITE=Lax
+# Транспортная безопасность (вне CHATBALLS_DEBUG включается автоматически).
+CHATBALLS_COOKIE_SECURE=true
+CHATBALLS_SSL_REDIRECT=true
+CHATBALLS_HSTS_SECONDS=31536000
+CHATBALLS_COOKIE_SAMESITE=Lax
# --- PostgreSQL / Redis ---
# C04 RLS: три раздельные DB-roles (app/platform/migration). Runtime-роли app и
# platform — NOBYPASSRLS, не владельцы tenant-tables; миграции выполняются под
-# migration-role (через CUS_DB_ROLE=migration в compose). Пароли СГЕНЕРИРОВАТЬ,
+# migration-role (через CHATBALLS_DB_ROLE=migration в compose). Пароли СГЕНЕРИРОВАТЬ,
# не использовать значения по умолчанию. См. deploy/postgres/init-runtime-roles.sh.
-POSTGRES_DB=custocrm
-POSTGRES_USER=custocrm
+POSTGRES_DB=chatballs
+POSTGRES_USER=chatballs
POSTGRES_PASSWORD=change-me-db-password
-POSTGRES_APP_USER=custocrm_app
+POSTGRES_APP_USER=chatballs_app
POSTGRES_APP_PASSWORD=change-me-app-db-password
-POSTGRES_PLATFORM_USER=custocrm_platform
+POSTGRES_PLATFORM_USER=chatballs_platform
POSTGRES_PLATFORM_PASSWORD=change-me-platform-db-password
-POSTGRES_MIGRATION_USER=custocrm_migration
+POSTGRES_MIGRATION_USER=chatballs_migration
POSTGRES_MIGRATION_PASSWORD=change-me-migration-db-password
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
# psycopg connection pool per процесс (backend работает под ASGI, где
# CONN_MAX_AGE-соединения осиротевают в thread-pool). Значения по умолчанию
# заданы в settings_database.py; переопределять обычно не нужно.
-# CUS_DB_POOL_MAX=0 отключает пул целиком.
-#CUS_DB_POOL_MIN=1
-#CUS_DB_POOL_MAX=4
-#CUS_DB_POOL_TIMEOUT=10
+# CHATBALLS_DB_POOL_MAX=0 отключает пул целиком.
+#CHATBALLS_DB_POOL_MIN=1
+#CHATBALLS_DB_POOL_MAX=4
+#CHATBALLS_DB_POOL_TIMEOUT=10
REDIS_URL=redis://redis:6379/0
# --- Tenant-owned object storage (C04) ---
# Production ТРЕБУЕТ S3-compatible backend (settings hard-fails без него вне
# debug/testing). Ключи хранятся как organizations/{organization_public_id}/...
-# CUS_S3_BUCKET обязателен; endpoint/region/credentials — для провайдера
+# CHATBALLS_S3_BUCKET обязателен; endpoint/region/credentials — для провайдера
# (Yandex Object Storage, MinIO, AWS S3 и т.п.).
-CUS_STORAGE_BACKEND=s3
-CUS_S3_BUCKET=
-CUS_S3_ENDPOINT_URL=
-CUS_S3_REGION=
-CUS_S3_ACCESS_KEY=
-CUS_S3_SECRET_KEY=
-CUS_S3_ADDRESSING_STYLE=path
-CUS_S3_URL_EXPIRY_SECONDS=900
+CHATBALLS_STORAGE_BACKEND=s3
+CHATBALLS_S3_BUCKET=
+CHATBALLS_S3_ENDPOINT_URL=
+CHATBALLS_S3_REGION=
+CHATBALLS_S3_ACCESS_KEY=
+CHATBALLS_S3_SECRET_KEY=
+CHATBALLS_S3_ADDRESSING_STYLE=path
+CHATBALLS_S3_URL_EXPIRY_SECONDS=900
# --- P2P calls (profile calls) ---
# Включается только при COMPOSE_PROFILES=calls. Параметры coturn обязательны,
# если profile активен.
-CUS_CALL_INVITE_TTL_SECONDS=300
-CUS_CALL_ACCESS_TTL_SECONDS=3600
-CUS_CALL_CONNECT_GRACE_SECONDS=120
-CUS_CALL_RECONNECT_GRACE_SECONDS=60
-CUS_CALL_STUN_URLS=
+CHATBALLS_CALL_INVITE_TTL_SECONDS=300
+CHATBALLS_CALL_ACCESS_TTL_SECONDS=3600
+CHATBALLS_CALL_CONNECT_GRACE_SECONDS=120
+CHATBALLS_CALL_RECONNECT_GRACE_SECONDS=60
+CHATBALLS_CALL_STUN_URLS=
# Публичные TURN endpoints (через запятую). Пусто -> только STUN/direct.
-CUS_CALL_TURN_URLS=
+CHATBALLS_CALL_TURN_URLS=
# Общий static-auth-secret между backend и coturn.
-CUS_CALL_TURN_SECRET=
-CUS_CALL_TURN_TTL_SECONDS=3600
-CUS_CALL_TURN_REALM=app.example.com
+CHATBALLS_CALL_TURN_SECRET=
+CHATBALLS_CALL_TURN_TTL_SECONDS=3600
+CHATBALLS_CALL_TURN_REALM=app.example.com
# Публичный IP coturn listener/relay. ОТДЕЛЬНЫЙ от web IP, чтобы TURN занял 443.
-CUS_TURN_EXTERNAL_IP=
-CUS_TURN_LISTENING_IP=
-CUS_TURN_LISTENING_PORT=3478
-CUS_TURN_TLS_PORT=443
-CUS_TURN_MIN_PORT=49160
-CUS_TURN_MAX_PORT=49200
+CHATBALLS_TURN_EXTERNAL_IP=
+CHATBALLS_TURN_LISTENING_IP=
+CHATBALLS_TURN_LISTENING_PORT=3478
+CHATBALLS_TURN_TLS_PORT=443
+CHATBALLS_TURN_MIN_PORT=49160
+CHATBALLS_TURN_MAX_PORT=49200
# --- AI provider ---
-CUS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
-CUS_CUSTOAI_API_KEY=
-CUS_CUSTOAI_BASE_URL=https://ai.api.cloud.yandex.net/v1
-CUS_CUSTOAI_MODEL=gpt://b1g89tr9t8iedhnl8pgg/yandexgpt-5.1/latest
-CUS_AI_REQUEST_TIMEOUT=30
-CUS_AI_MAX_RETRIES=2
-CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
+CHATBALLS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+CHATBALLS_CUSTOAI_API_KEY=
+CHATBALLS_CUSTOAI_BASE_URL=https://ai.api.cloud.yandex.net/v1
+CHATBALLS_CUSTOAI_MODEL=gpt://b1g89tr9t8iedhnl8pgg/yandexgpt-5.1/latest
+CHATBALLS_AI_REQUEST_TIMEOUT=30
+CHATBALLS_AI_MAX_RETRIES=2
+CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
# --- Email ---
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
@@ -124,8 +124,8 @@ EMAIL_PORT=587
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=true
-DEFAULT_FROM_EMAIL=CustoCRM
+DEFAULT_FROM_EMAIL=Chatballs
# --- Gunicorn ---
-CUS_GUNICORN_WORKERS=3
-CUS_GUNICORN_TIMEOUT=60
+CHATBALLS_GUNICORN_WORKERS=3
+CHATBALLS_GUNICORN_TIMEOUT=60
diff --git a/pyproject.toml b/pyproject.toml
index 686ef51..57a4579 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,4 +16,4 @@ plugins = ["mypy_django_plugin.main"]
ignore_missing_imports = true
[tool.django-stubs]
-django_settings_module = "hub_backend.settings"
+django_settings_module = "chatballs_backend.settings"
diff --git a/release.env.example b/release.env.example
index 9be67e1..e7948e7 100644
--- a/release.env.example
+++ b/release.env.example
@@ -2,13 +2,13 @@
# The file is immutable inside a release bundle and is never edited per instance.
# Every image reference is pinned by manifest digest; :latest is not a release source.
-CUSTOCRM_VERSION=1.0.0
-CUSTOCRM_BACKEND_IMAGE=registry.example.com/custocrm/backend:1.0.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-CUSTOCRM_FRONTEND_IMAGE=registry.example.com/custocrm/frontend:1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
-CUSTOCRM_POSTGRES_IMAGE=pgvector/pgvector:pg16@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
-CUSTOCRM_REDIS_IMAGE=redis:7-alpine@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd
-CUSTOCRM_GATEWAY_IMAGE=caddy:2.8.4@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
-CUSTOCRM_COTURN_IMAGE=coturn/coturn:4.6@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
+CHATBALLS_VERSION=1.0.0
+CHATBALLS_BACKEND_IMAGE=registry.example.com/chatballs/backend:1.0.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+CHATBALLS_FRONTEND_IMAGE=registry.example.com/chatballs/frontend:1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+CHATBALLS_POSTGRES_IMAGE=pgvector/pgvector:pg16@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
+CHATBALLS_REDIS_IMAGE=redis:7-alpine@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd
+CHATBALLS_GATEWAY_IMAGE=caddy:2.8.4@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
+CHATBALLS_COTURN_IMAGE=coturn/coturn:4.6@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
# Optional when voice-gateway is implemented:
-# CUSTOCRM_VOICE_GATEWAY_IMAGE=registry.example.com/custocrm/voice-gateway:1.0.0@sha256:1111111111111111111111111111111111111111111111111111111111111111
+# CHATBALLS_VOICE_GATEWAY_IMAGE=registry.example.com/chatballs/voice-gateway:1.0.0@sha256:1111111111111111111111111111111111111111111111111111111111111111
diff --git a/scripts/start.ps1 b/scripts/start.ps1
index 450433a..ace3aa9 100644
--- a/scripts/start.ps1
+++ b/scripts/start.ps1
@@ -12,17 +12,17 @@ if (-not (Test-Path ".env")) {
# Dev-контур: canonical compose.yaml + dev override (ADR-HUB-0028).
# Режим поставки задаётся процессу Compose и не переписывает локальный .env.
$deliveryMode = if ($Mode -eq "SelfHosted") { "SELF_HOSTED" } else { "CLOUD" }
-$previousDeliveryMode = $env:CUS_DELIVERY_MODE
-$env:CUS_DELIVERY_MODE = $deliveryMode
+$previousDeliveryMode = $env:CHATBALLS_DELIVERY_MODE
+$env:CHATBALLS_DELIVERY_MODE = $deliveryMode
try {
docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up --build
}
finally {
if ($null -eq $previousDeliveryMode) {
- Remove-Item Env:CUS_DELIVERY_MODE -ErrorAction SilentlyContinue
+ Remove-Item Env:CHATBALLS_DELIVERY_MODE -ErrorAction SilentlyContinue
}
else {
- $env:CUS_DELIVERY_MODE = $previousDeliveryMode
+ $env:CHATBALLS_DELIVERY_MODE = $previousDeliveryMode
}
}
diff --git a/scripts/start.sh b/scripts/start.sh
index 0d6942d..7edb539 100644
--- a/scripts/start.sh
+++ b/scripts/start.sh
@@ -12,8 +12,8 @@ fi
MODE="${1:-cloud}"
case "$MODE" in
- cloud) export CUS_DELIVERY_MODE=CLOUD ;;
- self-hosted) export CUS_DELIVERY_MODE=SELF_HOSTED ;;
+ cloud) export CHATBALLS_DELIVERY_MODE=CLOUD ;;
+ self-hosted) export CHATBALLS_DELIVERY_MODE=SELF_HOSTED ;;
*) echo "usage: $0 [cloud|self-hosted]" >&2; exit 2 ;;
esac
diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py
index e8fdaf7..7635fdf 100644
--- a/tests/cli/conftest.py
+++ b/tests/cli/conftest.py
@@ -1,6 +1,6 @@
-"""Pytest fixtures для custocrm CLI-харнесса.
+"""Pytest fixtures для chatballs CLI-харнесса.
-Тесты запускают реальный bash-скрипт custocrm против временного layout'а
+Тесты запускают реальный bash-скрипт chatballs против временного layout'а
instance + release, с замоканными `docker` и `flock` на PATH. Реальный Docker
не требуется (ADR-HUB-0028 §testing — machine-readable, без внешних зависимостей).
@@ -19,7 +19,7 @@ from pathlib import Path
import pytest
-REPO_RELEASE_ROOT = Path(__file__).resolve().parents[2] # code/custocrm
+REPO_RELEASE_ROOT = Path(__file__).resolve().parents[2] # code/chatballs
RELEASE_FILES = ["compose.yaml", "Caddyfile"]
LIB_GLOB_DIR = "deploy/cli/lib"
@@ -123,13 +123,13 @@ def fake_env(tmp_path: Path):
digest = "a" * 64
_write_lf(
release / "release.env",
- "CUSTOCRM_VERSION=1.0.0-test\n"
- f"CUSTOCRM_BACKEND_IMAGE=registry.test/backend:1.0.0@sha256:{digest}\n"
- f"CUSTOCRM_FRONTEND_IMAGE=registry.test/frontend:1.0.0@sha256:{digest}\n"
- f"CUSTOCRM_POSTGRES_IMAGE=pgvector/pgvector:pg16@sha256:{digest}\n"
- f"CUSTOCRM_REDIS_IMAGE=redis:7-alpine@sha256:{digest}\n"
- f"CUSTOCRM_GATEWAY_IMAGE=caddy:2.8.4@sha256:{digest}\n"
- f"CUSTOCRM_COTURN_IMAGE=coturn/coturn:4.6@sha256:{digest}\n",
+ "CHATBALLS_VERSION=1.0.0-test\n"
+ f"CHATBALLS_BACKEND_IMAGE=registry.test/backend:1.0.0@sha256:{digest}\n"
+ f"CHATBALLS_FRONTEND_IMAGE=registry.test/frontend:1.0.0@sha256:{digest}\n"
+ f"CHATBALLS_POSTGRES_IMAGE=pgvector/pgvector:pg16@sha256:{digest}\n"
+ f"CHATBALLS_REDIS_IMAGE=redis:7-alpine@sha256:{digest}\n"
+ f"CHATBALLS_GATEWAY_IMAGE=caddy:2.8.4@sha256:{digest}\n"
+ f"CHATBALLS_COTURN_IMAGE=coturn/coturn:4.6@sha256:{digest}\n",
)
checksum_lines = []
@@ -143,22 +143,22 @@ def fake_env(tmp_path: Path):
def write_env(**overrides) -> Path:
lines = {
- "COMPOSE_PROJECT_NAME": "custocrm_test",
- "CUSTOCRM_APP_DOMAIN": "app.test",
- "CUSTOCRM_PLATFORM_DOMAIN": "platform.test",
- "CUSTOCRM_ACME_EMAIL": "admin@test",
- "CUS_SECRET_KEY": "test-secret-not-default",
- "CUS_FIELD_ENCRYPTION_KEY": "",
- "POSTGRES_DB": "custocrm",
- "POSTGRES_USER": "custocrm",
+ "COMPOSE_PROJECT_NAME": "chatballs_test",
+ "CHATBALLS_APP_DOMAIN": "app.test",
+ "CHATBALLS_PLATFORM_DOMAIN": "platform.test",
+ "CHATBALLS_ACME_EMAIL": "admin@test",
+ "CHATBALLS_SECRET_KEY": "test-secret-not-default",
+ "CHATBALLS_FIELD_ENCRYPTION_KEY": "",
+ "POSTGRES_DB": "chatballs",
+ "POSTGRES_USER": "chatballs",
"POSTGRES_PASSWORD": "pg-secret",
"POSTGRES_HOST": "postgres",
"POSTGRES_PORT": "5432",
"REDIS_URL": "redis://redis:6379/0",
- "CUSTOCRM_APP_ALLOWED_HOSTS": "app.test",
- "CUSTOCRM_PLATFORM_ALLOWED_HOSTS": "platform.test",
- "CUSTOCRM_APP_HEALTHCHECK_HOST": "app.test",
- "CUSTOCRM_PLATFORM_HEALTHCHECK_HOST": "platform.test",
+ "CHATBALLS_APP_ALLOWED_HOSTS": "app.test",
+ "CHATBALLS_PLATFORM_ALLOWED_HOSTS": "platform.test",
+ "CHATBALLS_APP_HEALTHCHECK_HOST": "app.test",
+ "CHATBALLS_PLATFORM_HEALTHCHECK_HOST": "platform.test",
}
lines.update(overrides)
body = "".join(f"{k}={v}\n" for k, v in lines.items())
@@ -175,14 +175,14 @@ def fake_env(tmp_path: Path):
_write_lf(docker, FAKE_DOCKER)
_chmod_x(docker)
- custocrm = REPO_RELEASE_ROOT / "custocrm"
+ chatballs = REPO_RELEASE_ROOT / "chatballs"
def make_env() -> dict:
env = os.environ.copy()
sys_path = os.environ.get("PATH", "")
env["PATH"] = str(bin_dir) + os.pathsep + sys_path
- env["CUSTOCRM_RELEASE_DIR"] = str(release)
- env["CUSTOCRM_INSTANCE_DIR"] = str(instance)
+ env["CHATBALLS_RELEASE_DIR"] = str(release)
+ env["CHATBALLS_INSTANCE_DIR"] = str(instance)
env["FAKE_DOCKER_LOG"] = str(log_file)
# BASH-интерпретатор для скриптов-моков (env bash резолвится из PATH баша).
return env
@@ -196,7 +196,7 @@ def fake_env(tmp_path: Path):
e.instance = instance
e.bin = bin_dir
e.log = log_file
- e.custocrm = custocrm
+ e.chatballs = chatballs
e.bash = BASH_EXECUTABLE
e.write_env = write_env
e.install_flock = install_flock
diff --git a/tests/cli/test_custocrm_cli.py b/tests/cli/test_custocrm_cli.py
index 92fecc6..c08ef76 100644
--- a/tests/cli/test_custocrm_cli.py
+++ b/tests/cli/test_custocrm_cli.py
@@ -1,241 +1,241 @@
-"""Тесты custocrm CLI (этап 1): deploy workflow ordering, validation, lock, doctor, status.
-
-Запускают реальный bash-скрипт против замоканного docker/flock (см. conftest.py).
-Покрывают: SPEC-HUB-0019 §11 (lock), §12 (doctor), §17 (deploy workflow),
-§29 (observability). Не требуют Docker daemon.
-"""
-
-from __future__ import annotations
-
-import hashlib
-import subprocess
-
-
-def _run(env, *args):
- """Вызывает custocrm через bash с окружением из fake_env."""
- return subprocess.run(
- [env.bash, str(env.custocrm), *args],
- env=env.make_env(),
- cwd=str(env.instance),
- capture_output=True,
- text=True,
- )
-
-
-def _rewrite_checksums(env):
- lines = []
- release_files = (
- path for path in env.release.rglob("*") if path.is_file() and path.name != "checksums.txt"
- )
- for path in sorted(release_files):
- digest = hashlib.sha256(path.read_bytes()).hexdigest()
- relative = path.relative_to(env.release).as_posix()
- lines.append(f"{digest} ./{relative}\n")
- (env.release / "checksums.txt").write_text("".join(lines), encoding="utf-8")
-
-
-def _log_lines(env):
- if not env.log.exists():
- return []
- return [line for line in env.log.read_text().splitlines() if line.strip()]
-
-
-def _index_of(log, fragment):
- return next(index for index, line in enumerate(log) if fragment in line)
-
-
-# ---------------------------------------------------------------------------
-# deploy
-# ---------------------------------------------------------------------------
-
-
-def test_deploy_success_orders_canonical_workflow(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- r = _run(fake_env, "deploy", "--non-interactive")
-
- assert r.returncode == 0, r.stderr
- log = _log_lines(fake_env)
- joined = "\n".join(log)
-
- # Канонический порядок (ADR-HUB-0028 §workflow).
- idx_pull = _index_of(log, " pull")
- idx_infra = _index_of(log, " up -d postgres redis")
- idx_init = _index_of(log, "run --rm init")
- idx_app = _index_of(
- log,
- " up -d backend-app backend-platform backend-admin worker frontend gateway",
- )
- idx_exec = _index_of(log, "exec -T backend-app")
-
- assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined
- # applied_release записан.
- assert (fake_env.instance / "state" / "applied_release").exists()
- assert not (fake_env.instance / "compose.yaml").exists()
-
-
-def test_deploy_includes_coturn_when_calls_profile_active(fake_env):
- fake_env.write_env(
- COMPOSE_PROFILES="calls",
- CUSTOCRM_WEB_LISTENING_IP="203.0.113.10",
- CUS_CALL_TURN_SECRET="turn-secret",
- CUS_CALL_TURN_REALM="turn.hub.test",
- CUS_TURN_EXTERNAL_IP="203.0.113.11",
- CUS_TURN_LISTENING_IP="203.0.113.11",
- )
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- r = _run(fake_env, "deploy", "--non-interactive")
- assert r.returncode == 0, r.stderr
- joined = "\n".join(_log_lines(fake_env))
- assert (
- "up -d backend-app backend-platform backend-admin worker frontend gateway coturn"
- in joined
- )
-
-
-def test_deploy_rejects_shared_web_and_turn_ip(fake_env):
- fake_env.write_env(
- COMPOSE_PROFILES="calls",
- CUSTOCRM_WEB_LISTENING_IP="203.0.113.10",
- CUS_CALL_TURN_SECRET="turn-secret",
- CUS_CALL_TURN_REALM="turn.hub.test",
- CUS_TURN_EXTERNAL_IP="203.0.113.10",
- CUS_TURN_LISTENING_IP="203.0.113.10",
- )
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- result = _run(fake_env, "deploy", "--non-interactive")
-
- assert result.returncode != 0
- assert "different public IP" in result.stderr
- assert " pull" not in "\n".join(_log_lines(fake_env))
-
-
-def test_deploy_fails_when_release_env_missing(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=False)
- # Удаляем release.env — релиз не активирован.
- (fake_env.release / "release.env").unlink()
-
- r = _run(fake_env, "deploy", "--non-interactive")
- assert r.returncode != 0
- joined = "\n".join(_log_lines(fake_env))
- assert " up -d " not in joined # до запуска контейнеров не дошло
-
-
-def test_deploy_fails_when_release_checksum_is_invalid(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=False)
- (fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8")
-
- r = _run(fake_env, "deploy", "--non-interactive")
-
- assert r.returncode != 0
- assert "checksum" in r.stderr.lower()
- assert " pull" not in "\n".join(_log_lines(fake_env))
-
-
-def test_deploy_fails_when_release_image_is_not_digest_pinned(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=False)
- release_env = fake_env.release / "release.env"
- release_env.write_text(
- release_env.read_text().replace(
- "registry.test/backend:1.0.0@sha256:" + "a" * 64,
- "registry.test/backend:latest",
- )
- )
- _rewrite_checksums(fake_env)
-
- r = _run(fake_env, "deploy", "--non-interactive")
-
- assert r.returncode != 0
- assert "immutable" in r.stderr.lower()
- assert " pull" not in "\n".join(_log_lines(fake_env))
-
-
-def test_deploy_fails_on_lock_held(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=True) # блокировка занята
-
- r = _run(fake_env, "deploy", "--non-interactive")
- assert r.returncode == 3, r.stderr
- assert "lock" in r.stderr.lower() or "lock" in r.stdout.lower()
- joined = "\n".join(_log_lines(fake_env))
- assert " up -d " not in joined # destructive операция не началась
-
-
-# ---------------------------------------------------------------------------
-# doctor
-# ---------------------------------------------------------------------------
-
-
-def test_doctor_passes_on_valid_env(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- r = _run(fake_env, "doctor")
- assert r.returncode == 0, r.stderr
-
-
-def test_doctor_fails_on_default_secret_and_missing_password(fake_env):
- fake_env.write_env(
- CUS_SECRET_KEY="change-me-long-random-secret", # default -> должно провалиться
- POSTGRES_PASSWORD="", # пусто -> должно провалиться
- )
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- r = _run(fake_env, "doctor")
- assert r.returncode == 1
- assert "POSTGRES_PASSWORD" in r.stderr
- assert "CUS_SECRET_KEY" in r.stderr
-
-
-def test_doctor_fails_when_acme_email_missing(fake_env):
- fake_env.write_env(CUSTOCRM_ACME_EMAIL="")
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- result = _run(fake_env, "doctor")
-
- assert result.returncode == 1
- assert "CUSTOCRM_ACME_EMAIL" in result.stderr
-
-
-def test_doctor_fails_when_surface_domains_match(fake_env):
- fake_env.write_env(CUSTOCRM_PLATFORM_DOMAIN="app.test")
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- result = _run(fake_env, "doctor")
-
- assert result.returncode == 1
- assert "must be distinct" in result.stderr
-
-
-# ---------------------------------------------------------------------------
-# status
-# ---------------------------------------------------------------------------
-
-
-def test_status_json_reports_target_version(fake_env):
- fake_env.write_env()
- fake_env.install_docker()
- fake_env.install_flock(held=False)
-
- r = _run(fake_env, "status", "--json")
- assert r.returncode == 0, r.stderr
- out = r.stdout.strip()
- assert out.startswith("{") and '"target_version"' in out
- assert "1.0.0-test" in out
+"""Тесты chatballs CLI (этап 1): deploy workflow ordering, validation, lock, doctor, status.
+
+Запускают реальный bash-скрипт против замоканного docker/flock (см. conftest.py).
+Покрывают: SPEC-HUB-0019 §11 (lock), §12 (doctor), §17 (deploy workflow),
+§29 (observability). Не требуют Docker daemon.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import subprocess
+
+
+def _run(env, *args):
+ """Вызывает chatballs через bash с окружением из fake_env."""
+ return subprocess.run(
+ [env.bash, str(env.chatballs), *args],
+ env=env.make_env(),
+ cwd=str(env.instance),
+ capture_output=True,
+ text=True,
+ )
+
+
+def _rewrite_checksums(env):
+ lines = []
+ release_files = (
+ path for path in env.release.rglob("*") if path.is_file() and path.name != "checksums.txt"
+ )
+ for path in sorted(release_files):
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
+ relative = path.relative_to(env.release).as_posix()
+ lines.append(f"{digest} ./{relative}\n")
+ (env.release / "checksums.txt").write_text("".join(lines), encoding="utf-8")
+
+
+def _log_lines(env):
+ if not env.log.exists():
+ return []
+ return [line for line in env.log.read_text().splitlines() if line.strip()]
+
+
+def _index_of(log, fragment):
+ return next(index for index, line in enumerate(log) if fragment in line)
+
+
+# ---------------------------------------------------------------------------
+# deploy
+# ---------------------------------------------------------------------------
+
+
+def test_deploy_success_orders_canonical_workflow(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ r = _run(fake_env, "deploy", "--non-interactive")
+
+ assert r.returncode == 0, r.stderr
+ log = _log_lines(fake_env)
+ joined = "\n".join(log)
+
+ # Канонический порядок (ADR-HUB-0028 §workflow).
+ idx_pull = _index_of(log, " pull")
+ idx_infra = _index_of(log, " up -d postgres redis")
+ idx_init = _index_of(log, "run --rm init")
+ idx_app = _index_of(
+ log,
+ " up -d backend-app backend-platform backend-admin worker frontend gateway",
+ )
+ idx_exec = _index_of(log, "exec -T backend-app")
+
+ assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined
+ # applied_release записан.
+ assert (fake_env.instance / "state" / "applied_release").exists()
+ assert not (fake_env.instance / "compose.yaml").exists()
+
+
+def test_deploy_includes_coturn_when_calls_profile_active(fake_env):
+ fake_env.write_env(
+ COMPOSE_PROFILES="calls",
+ CHATBALLS_WEB_LISTENING_IP="203.0.113.10",
+ CHATBALLS_CALL_TURN_SECRET="turn-secret",
+ CHATBALLS_CALL_TURN_REALM="turn.hub.test",
+ CHATBALLS_TURN_EXTERNAL_IP="203.0.113.11",
+ CHATBALLS_TURN_LISTENING_IP="203.0.113.11",
+ )
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ r = _run(fake_env, "deploy", "--non-interactive")
+ assert r.returncode == 0, r.stderr
+ joined = "\n".join(_log_lines(fake_env))
+ assert (
+ "up -d backend-app backend-platform backend-admin worker frontend gateway coturn"
+ in joined
+ )
+
+
+def test_deploy_rejects_shared_web_and_turn_ip(fake_env):
+ fake_env.write_env(
+ COMPOSE_PROFILES="calls",
+ CHATBALLS_WEB_LISTENING_IP="203.0.113.10",
+ CHATBALLS_CALL_TURN_SECRET="turn-secret",
+ CHATBALLS_CALL_TURN_REALM="turn.hub.test",
+ CHATBALLS_TURN_EXTERNAL_IP="203.0.113.10",
+ CHATBALLS_TURN_LISTENING_IP="203.0.113.10",
+ )
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ result = _run(fake_env, "deploy", "--non-interactive")
+
+ assert result.returncode != 0
+ assert "different public IP" in result.stderr
+ assert " pull" not in "\n".join(_log_lines(fake_env))
+
+
+def test_deploy_fails_when_release_env_missing(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+ # Удаляем release.env — релиз не активирован.
+ (fake_env.release / "release.env").unlink()
+
+ r = _run(fake_env, "deploy", "--non-interactive")
+ assert r.returncode != 0
+ joined = "\n".join(_log_lines(fake_env))
+ assert " up -d " not in joined # до запуска контейнеров не дошло
+
+
+def test_deploy_fails_when_release_checksum_is_invalid(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+ (fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8")
+
+ r = _run(fake_env, "deploy", "--non-interactive")
+
+ assert r.returncode != 0
+ assert "checksum" in r.stderr.lower()
+ assert " pull" not in "\n".join(_log_lines(fake_env))
+
+
+def test_deploy_fails_when_release_image_is_not_digest_pinned(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+ release_env = fake_env.release / "release.env"
+ release_env.write_text(
+ release_env.read_text().replace(
+ "registry.test/backend:1.0.0@sha256:" + "a" * 64,
+ "registry.test/backend:latest",
+ )
+ )
+ _rewrite_checksums(fake_env)
+
+ r = _run(fake_env, "deploy", "--non-interactive")
+
+ assert r.returncode != 0
+ assert "immutable" in r.stderr.lower()
+ assert " pull" not in "\n".join(_log_lines(fake_env))
+
+
+def test_deploy_fails_on_lock_held(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=True) # блокировка занята
+
+ r = _run(fake_env, "deploy", "--non-interactive")
+ assert r.returncode == 3, r.stderr
+ assert "lock" in r.stderr.lower() or "lock" in r.stdout.lower()
+ joined = "\n".join(_log_lines(fake_env))
+ assert " up -d " not in joined # destructive операция не началась
+
+
+# ---------------------------------------------------------------------------
+# doctor
+# ---------------------------------------------------------------------------
+
+
+def test_doctor_passes_on_valid_env(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ r = _run(fake_env, "doctor")
+ assert r.returncode == 0, r.stderr
+
+
+def test_doctor_fails_on_default_secret_and_missing_password(fake_env):
+ fake_env.write_env(
+ CHATBALLS_SECRET_KEY="change-me-long-random-secret", # default -> должно провалиться
+ POSTGRES_PASSWORD="", # пусто -> должно провалиться
+ )
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ r = _run(fake_env, "doctor")
+ assert r.returncode == 1
+ assert "POSTGRES_PASSWORD" in r.stderr
+ assert "CHATBALLS_SECRET_KEY" in r.stderr
+
+
+def test_doctor_fails_when_acme_email_missing(fake_env):
+ fake_env.write_env(CHATBALLS_ACME_EMAIL="")
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ result = _run(fake_env, "doctor")
+
+ assert result.returncode == 1
+ assert "CHATBALLS_ACME_EMAIL" in result.stderr
+
+
+def test_doctor_fails_when_surface_domains_match(fake_env):
+ fake_env.write_env(CHATBALLS_PLATFORM_DOMAIN="app.test")
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ result = _run(fake_env, "doctor")
+
+ assert result.returncode == 1
+ assert "must be distinct" in result.stderr
+
+
+# ---------------------------------------------------------------------------
+# status
+# ---------------------------------------------------------------------------
+
+
+def test_status_json_reports_target_version(fake_env):
+ fake_env.write_env()
+ fake_env.install_docker()
+ fake_env.install_flock(held=False)
+
+ r = _run(fake_env, "status", "--json")
+ assert r.returncode == 0, r.stderr
+ out = r.stdout.strip()
+ assert out.startswith("{") and '"target_version"' in out
+ assert "1.0.0-test" in out