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