🔥 refactor: снос сущности Product и авторизованного in-product чата (ADR-HUB-0045)

`Product` оставался в схеме «скрытой технической осью» (ADR-HUB-0041 §7), но
из интерфейса не исчез: в настройках портала жил раздел «Продукты и поддержка»,
в списке порталов — колонка «Продукты». Решение владельца — убрать сущность
целиком вместе со всем контуром, который на ней держался.

Удалено:
- приложения `products` и `support` (контракты идентификации, снимки личности,
  Product Support Token, публичные endpoints сессии);
- `Channel.product` и `requires_authenticated_product_identity` (инварианты
  P3-P5; остались P1-P2), `LlmInvocation.product`, `SupportPortalProduct`;
- `WebChatWidget.mode` целиком: различать было нечего, у веб-виджета одна
  анонимная точка входа. Ключ `mode` ушёл и из ответов API виджета;
- `mode=support` и `ChatballsChat.init` в лоадере, `SupportApp` в web-chat;
- capability `products.*`, аудит-действия `products.*`, продукты в карточке
  контакта и в статистике (`byProduct`);
- раздел настроек портала и колонка списка — в коде и в дизайн-базлайне v2.

У диалога один источник identity — контакт: XOR заменён на
`conversation_requires_contact`. `PUT /portals/{id}/products/` удалён,
`GET /portals/{id}/support-channels/` → `GET /portals/{id}/widgets/`.

Миграции — по образцу сноса продаж: исторические миграции уцелевших приложений
вычищены от ссылок (чистая установка объектов не создаёт), существующие БД
чинит `tenancy/0029_drop_product_support`. Данные не экспортируются, но диалоги
со снимком личности не удаляются — каждому создаётся контакт с именем из снимка.

Проверено: backend 583 OK, фронт tsc + vitest 77 OK, сборки internal-ui и
web-chat OK, миграция прогнана на локальной БД, публичный портал открывается.

В коммит также вошли накопленные незакоммиченные правки рабочего дерева
(библиотека знаний, настройки, dev-compose и nginx, макеты базлайна).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-08 00:39:12 +03:00
1 parent 41a4b0dd84
commit 146052916b
234 files changed
+6883 -7895

No files matched your search

-1
View File
@@ -286,7 +286,6 @@ def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
counts = (
("conversations", channel.conversations.count()),
("connections", channel.connections.count()),
("supportContracts", channel.allowed_support_contracts.count()),
("llmInvocations", channel.ai_invocations.count()),
)
return [{"type": name, "count": count} for name, count in counts if count]
-4
View File
@@ -23,7 +23,6 @@ def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> N
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
product=channel.product,
purpose=purpose,
operation="chat",
model=model,
@@ -78,7 +77,6 @@ def invoke_chat(
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
product=channel.product,
purpose=purpose,
operation="chat",
model=model,
@@ -92,7 +90,6 @@ def invoke_chat(
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
product=channel.product,
purpose=purpose,
operation="chat",
model=result.model,
@@ -126,7 +123,6 @@ def embed_texts(
LlmInvocation.objects.create(
organization=channel.organization if channel else organization,
channel=channel,
product=(channel.product if channel else None),
purpose=purpose,
operation="embedding",
model=model,
@@ -14,7 +14,6 @@ class Migration(migrations.Migration):
dependencies = [
('channels', '0002_channel_system_prompt'),
('identity', '0006_alter_employeeprofile_totp_secret'),
('products', '0005_remove_price_price_amount_positive_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -69,7 +68,6 @@ class Migration(migrations.Migration):
('category', models.CharField(choices=[('OVERVIEW', 'Обзор продукта'), ('AUDIENCE', 'Целевая аудитория'), ('COMMERCIAL', 'Коммерческая модель'), ('TECHNICAL', 'Техническая информация'), ('FAQ', 'FAQ'), ('OBJECTIONS', 'Возражения'), ('LIMITATIONS', 'Ограничения')], max_length=32)),
('inclusion_mode', models.CharField(choices=[('MANDATORY', 'Обязательное включение'), ('RETRIEVAL', 'Доступно для retrieval')], default='RETRIEVAL', max_length=16)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='identity.organization')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')),
],
),
migrations.CreateModel(
@@ -120,7 +118,6 @@ class Migration(migrations.Migration):
('used_fragment_ids', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('channel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_invocations', to='channels.channel')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ai_invocations', to='products.product')),
('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invocations', to='ai.channelairelease')),
],
options={
@@ -139,7 +136,6 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)),
('category', models.CharField(choices=[('SYSTEM', 'Системный промпт'), ('QUALIFICATION', 'Квалификация'), ('SALES_BEHAVIOR', 'Поведение в продаже'), ('OPERATOR_HANDOFF', 'Передача оператору')], max_length=32)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='identity.organization')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')),
],
),
migrations.CreateModel(
@@ -182,10 +178,6 @@ class Migration(migrations.Migration):
model_name='channelairelease',
constraint=models.UniqueConstraint(condition=models.Q(('status', 'PUBLISHED')), fields=('channel',), name='uniq_active_release_per_channel'),
),
migrations.AddConstraint(
model_name='knowledgedocument',
constraint=models.UniqueConstraint(fields=('organization', 'product', 'code'), name='uniq_knowledge_doc_org_product_code'),
),
migrations.AddConstraint(
model_name='knowledgedocumentversion',
constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_knowledge_version'),
@@ -198,10 +190,6 @@ class Migration(migrations.Migration):
model_name='llminvocation',
index=models.Index(fields=['channel', 'created_at'], name='ai_llminvoc_channel_dbf239_idx'),
),
migrations.AddConstraint(
model_name='promptdocument',
constraint=models.UniqueConstraint(fields=('organization', 'product', 'code'), name='uniq_prompt_doc_org_product_code'),
),
migrations.AddConstraint(
model_name='promptdocumentversion',
constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_prompt_version'),
@@ -20,12 +20,9 @@ class Migration(migrations.Migration):
SELECT 1
FROM ai_llminvocation invocation
LEFT JOIN channels_channel channel ON channel.id = invocation.channel_id
LEFT JOIN identity_product product ON product.id = invocation.product_id
WHERE (channel.id IS NULL AND product.id IS NULL)
OR (channel.id IS NOT NULL AND product.id IS NOT NULL
AND channel.organization_id <> product.organization_id)
WHERE channel.id IS NULL
) THEN
RAISE EXCEPTION 'C04 preflight: ambiguous or cross-tenant LLM invocation exists';
RAISE EXCEPTION 'C04 preflight: ambiguous LLM invocation exists';
END IF;
END $$;
@@ -48,12 +45,6 @@ class Migration(migrations.Migration):
SET organization_id = channel.organization_id
FROM channels_channel channel
WHERE channel.id = invocation.channel_id;
UPDATE ai_llminvocation invocation
SET organization_id = product.organization_id
FROM identity_product product
WHERE invocation.organization_id IS NULL
AND product.id = invocation.product_id;
""",
reverse_sql=migrations.RunSQL.noop,
),
+2 -3
View File
@@ -232,10 +232,9 @@ class LlmInvocationStatus(models.TextChoices):
␍
␍
class LlmInvocation(TenantRelationModel):␍
tenant_relation_fields = ("channel", "product")␍
# Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый.␍
tenant_relation_fields = ("channel",)␍
# Учёт по каналу (ADR-HUB-0019).␍
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")␍
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True)␍
purpose = models.CharField(max_length=64)␍
operation = models.CharField(max_length=16) # chat | embedding␍
model = models.CharField(max_length=128, blank=True)␍
+35 -1
View File
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from dataclasses import dataclass
from django.db.models import Count, Prefetch, Q, QuerySet
@@ -6,13 +7,46 @@ from chatballs.ai.agent_knowledge import knowledge_available_to_channel
from chatballs.ai.knowledge_policy import readable_knowledge, writable_knowledge
from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory
from chatballs.channels.models import Channel
from chatballs.identity.models import AuditEvent
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.context import TenantContext
KNOWLEDGE_EDIT_ACTIONS = ("ai.knowledge_created", "ai.knowledge_updated")
def knowledge_editors(
*, organization_id: int, knowledge_ids: Sequence[int]
) -> dict[int, str]:
"""Кто последним правил каждое знание — подпись под датой в колонке
«Обновлено» (дизайн-базлайн v2, кадр KB1). Один запрос на весь список:
события создания и правки уже пишутся в журнал."""
if not knowledge_ids:
return {}
events = (
AuditEvent.objects.filter(
organization_id=organization_id,
action__in=KNOWLEDGE_EDIT_ACTIONS,
object_type="Knowledge",
object_id__in=[str(knowledge_id) for knowledge_id in knowledge_ids],
)
.select_related("actor")
.order_by("object_id", "-created_at")
)
editors: dict[int, str] = {}
for event in events:
if event.actor is None:
continue
try:
knowledge_id = int(event.object_id)
except (TypeError, ValueError):
continue
editors.setdefault(knowledge_id, event.actor.full_name or event.actor.email)
return editors
def agents_for_context(context: TenantContext) -> QuerySet[AIAgent]:
return (
AIAgent.objects.select_related("channel", "channel__group", "channel__product")
AIAgent.objects.select_related("channel", "channel__group")
.prefetch_related(
"knowledge_items",
"portal_articles__portal",
@@ -29,3 +29,31 @@ class KnowledgeDetailPayloadTests(TestCase):
self.assertTrue(detail["createdBy"])
self.assertGreater(detail["fragmentsCount"], 0)
self.assertNotIn("createdBy", listed)
# Автор последней правки стоит под датой в колонке «Обновлено»
# (дизайн-базлайн v2, кадр KB1), поэтому он есть и в списке.
self.assertEqual(listed["updatedBy"], detail["updatedBy"])
self.assertTrue(listed["updatedBy"])
class KnowledgeReindexTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
organization = Organization.objects.get(slug="demo")
self.base = f"/api/v1/organizations/{organization.public_id}/ai/knowledge"
def test_reindex_rebuilds_fragments_of_knowledge(self) -> None:
created = self.client.post(
f"{self.base}/",
data=json.dumps({"title": "Сроки", "content": "Рубашка — 10 дней.\n\nПальто — 25 дней."}),
content_type="application/json",
)
knowledge_id = created.json()["knowledge"]["id"]
before = self.client.get(f"{self.base}/{knowledge_id}/").json()["knowledge"]["fragmentsCount"]
response = self.client.post(f"{self.base}/{knowledge_id}/reindex/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["knowledge"]["fragmentsCount"], before)
self.assertGreater(before, 0)
@@ -20,7 +20,6 @@ from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.products.models import Product
from chatballs.testing import system_tenant_context
@@ -35,7 +34,6 @@ class ProviderModeTests(TestCase):
self.organization,
code="provider-mode-sales",
name="Provider mode — продажи",
product=Product.objects.get(code="site"),
)
def _link_integration(
+2 -12
View File
@@ -10,15 +10,14 @@ from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFrag
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.products.models import Product
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
def make_channel_with_agent(organization, *, code, name, product=None, model="openai/gpt-4o-mini"):
def make_channel_with_agent(organization, *, code, name, model="openai/gpt-4o-mini"):
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт
каналы/агентов — в тестах их собирает этот helper."""
channel = Channel.objects.create(organization=organization, code=code, name=name, product=product)
channel = Channel.objects.create(organization=organization, code=code, name=name)
agent = AIAgent.objects.create(
channel=channel,
name=f"{name} Agent",
@@ -49,7 +48,6 @@ class AIAgentInvariantTests(TestCase):
channel, _ = make_channel_with_agent(
self.organization, code="site-sales", name="Сайт — продажи",
product=Product.objects.get(code="site"),
)
with self.assertRaises(ValidationError):
create_agent(
@@ -64,11 +62,6 @@ class AIAgentInvariantTests(TestCase):
),
)
def test_new_product_does_not_get_an_agent_automatically(self) -> None:
product = Product.objects.create(organization=self.organization, code="academy", name="Academy")
self.assertFalse(AIAgent.objects.filter(channel__product=product).exists())
class AIAgentPermissionTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
@@ -328,7 +321,6 @@ class ChatInvocationTests(TestCase):
self.organization = Organization.objects.get(slug="demo")
self.channel, self.agent = make_channel_with_agent(
self.organization, code="site-sales", name="Сайт — продажи",
product=Product.objects.get(code="site"),
)
def test_chat_records_invocation_with_cost(self) -> None:
@@ -447,7 +439,6 @@ class KnowledgeRetrievalTests(TestCase):
self.organization = Organization.objects.get(slug="demo")
self.channel, self.agent = make_channel_with_agent(
self.organization, code="site-sales", name="Сайт — продажи",
product=Product.objects.get(code="site"),
)
from chatballs.ai.services import KnowledgeInput, create_knowledge
@@ -494,7 +485,6 @@ class AgentRuntimeTests(TestCase):
self.organization = Organization.objects.get(slug="demo")
self.channel, self.agent = make_channel_with_agent(
self.organization, code="site-sales", name="Сайт — продажи",
product=Product.objects.get(code="site"),
)
self.agent.persona = "Ты — ассистент Сайт."
self.agent.tone = "Коротко."
+5
View File
@@ -41,6 +41,11 @@ urlpatterns = [
views.KnowledgeDetailView.as_view(),
name="ai-knowledge-detail",
),
path(
"knowledge/<int:knowledge_id>/reindex/",
views.KnowledgeReindexView.as_view(),
name="ai-knowledge-reindex",
),
path(
"knowledge/<int:knowledge_id>/attachments/",
views.KnowledgeAttachmentUploadView.as_view(),
+38 -3
View File
@@ -18,9 +18,11 @@ from chatballs.ai.knowledge_services import (
delete_knowledge,
update_knowledge,
)
from chatballs.ai.indexing import reindex_knowledge
from chatballs.ai.models import Knowledge
from chatballs.ai.selectors import (
apply_knowledge_filters,
knowledge_editors,
knowledge_for_context,
knowledge_item_for_context,
writable_knowledge_item_for_employee,
@@ -71,10 +73,21 @@ class KnowledgeListCreateView(_KnowledgeBaseView):
filters = knowledge_filters(request)
except ValidationError as error:
return _validation_error(error)
items = apply_knowledge_filters(knowledge_for_context(request.tenant_context), filters)
return Response(
{"items": [knowledge_payload(item, include_content=False) for item in items]}
items = list(apply_knowledge_filters(knowledge_for_context(request.tenant_context), filters))
# Автор последней правки нужен в списке: он стоит под датой в колонке
# «Обновлено» (кадр KB1). Один запрос на всю страницу, не N+1.
editors = knowledge_editors(
organization_id=self._org(request).id,
knowledge_ids=[item.id for item in items],
)
payloads = []
for item in items:
payload = knowledge_payload(item, include_content=False)
editor = editors.get(item.id)
if editor:
payload["updatedBy"] = editor
payloads.append(payload)
return Response({"items": payloads})
def post(self, request: Request) -> Response:
try:
@@ -111,6 +124,12 @@ class KnowledgeDetailView(_KnowledgeBaseView):
)
if created_event and created_event.actor:
payload["createdBy"] = created_event.actor.full_name or created_event.actor.email
editor = knowledge_editors(
organization_id=knowledge.organization_id,
knowledge_ids=[knowledge.id],
).get(knowledge.id)
if editor:
payload["updatedBy"] = editor
return Response({"knowledge": payload})
def patch(self, request: Request, knowledge_id: int) -> Response:
@@ -168,6 +187,22 @@ class KnowledgeImportView(_KnowledgeBaseView):
return Response(payload, status=201)
class KnowledgeReindexView(_KnowledgeBaseView):
"""Пересборка фрагментов знания по требованию (дизайн-базлайн v2, кадры
KB1/KB4). Обычно индекс перестраивается при сохранении; кнопка нужна, когда
агент отвечает старым текстом после сбоя или правки вложения."""
def post(self, request: Request, knowledge_id: int) -> Response:
try:
knowledge = self._write_knowledge(request, knowledge_id)
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
reindex_knowledge(knowledge)
self._audit(request, "reindexed", knowledge)
knowledge = self._write_knowledge(request, knowledge_id)
return Response({"knowledge": knowledge_payload(knowledge)})
class KnowledgeAttachmentUploadView(_KnowledgeBaseView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
+1 -1
View File
@@ -5,6 +5,6 @@ from chatballs.channels.models import Channel
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = ("name", "code", "product", "group", "is_active")
list_display = ("name", "code", "group", "is_active")
list_filter = ("is_active",)
search_fields = ("name", "code")
@@ -11,7 +11,6 @@ class Migration(migrations.Migration):
dependencies = [
('identity', '0006_alter_employeeprofile_totp_secret'),
('integrations', '0001_initial'),
('products', '0005_remove_price_price_amount_positive_and_more'),
]
operations = [
@@ -28,7 +27,6 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='identity.department')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='identity.organization')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='products.product')),
('provider_integration', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='channels', to='integrations.integration')),
],
options={
@@ -30,9 +30,4 @@ class Migration(migrations.Migration):
name='allow_self_reported_contact',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='channel',
name='requires_authenticated_product_identity',
field=models.BooleanField(default=False),
),
]
@@ -1,37 +1,19 @@
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
#
# Приводит существующие каналы в соответствие P1-P5 и меняет дефолты модели,
# которые сами по себе нарушали P1-P2: канал создаётся без продукта, а
# attribution и checkout были включены по умолчанию.
# Приводит существующие каналы в соответствие P1-P2 и меняет дефолты модели,
# которые сами по себе их нарушали: attribution и checkout были включены по
# умолчанию.
#
# Решение владельца по унаследованным непродуктовым каналам: выключить
# checkout и attribution, а не назначать продукт. Поэтому миграция чинит ровно
# P1 и P2. Нарушения P3-P5 она не трогает и останавливает выкат: их
# исправление меняет смысл канала (обязательная идентичность), и выбирать за
# владельца здесь нельзя.
# Решение владельца по унаследованным каналам: выключить checkout и
# attribution. Продуктовая часть инвариантов (P3-P5) снята вместе с сущностью
# Product (ADR-HUB-0041).
from django.db import migrations, models
def relax_non_product_channels(apps, schema_editor):
Channel = apps.get_model("channels", "Channel")
blocked = list(
Channel.objects.filter(requires_authenticated_product_identity=True)
.filter(
models.Q(product__isnull=True)
| models.Q(allow_anonymous_sessions=True)
| models.Q(allow_self_reported_contact=True)
)
.values_list("id", "code")
)
if blocked:
listed = ", ".join(f"{code} (id={channel_id})" for channel_id, code in blocked)
raise RuntimeError(
"Каналы нарушают P3-P5 и требуют решения владельца до включения "
f"инвариантов: {listed}"
)
Channel.objects.filter(product__isnull=True).filter(
Channel.objects.filter(
models.Q(allow_checkout_actions=True) | models.Q(allow_sales_attribution=True)
).update(allow_checkout_actions=False, allow_sales_attribution=False)
+6 -10
View File
@@ -1,8 +1,8 @@
from django.db import models
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Опциональный продукт,
# группа видимости, ссылка на провайдер-интеграцию. Поведение AI (модель,
# инструкции, знания) живёт на агенте канала (ADR-HUB-0023).
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и
# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания)
# живёт на агенте канала (ADR-HUB-0023).
class Channel(models.Model):
@@ -12,16 +12,12 @@ class Channel(models.Model):
# Группа видимости (ADR-HUB-0043): новые диалоги канала попадают в неё.
# NULL — диалоги видны всем сотрудникам.
group = models.ForeignKey("identity.EmployeeGroup", on_delete=models.SET_NULL, related_name="channels", null=True, blank=True)
# Продукт опционален: непродуктовый канал — сайт компании.
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
# LLM-провайдер канала (ADR-HUB-0020).
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
is_active = models.BooleanField(default=True)
# Политика канала (SPEC-HUB-0010 §4.2, SPEC-HUB-0027 §3.2). Дефолты обязаны
# удовлетворять инвариантам P1-P5 при product = null: канал создаётся без
# продукта, поэтому коммерческие флаги по умолчанию выключены. Продуктовый
# канал включает их явно — пресетом SALES или политикой в запросе.
requires_authenticated_product_identity = models.BooleanField(default=False)
# Политика канала: остаток от домена продаж — коммерческие флаги всегда
# выключены (ADR-HUB-0041/0045), анонимные сессии и самозаявленный контакт
# используются веб-виджетом и порталом.
allow_anonymous_sessions = models.BooleanField(default=True)
allow_self_reported_contact = models.BooleanField(default=True)
allow_sales_attribution = models.BooleanField(default=False)
+21 -84
View File
@@ -1,10 +1,8 @@
"""Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7).␍
␍
Источник истины — пять булевых полей `Channel`. Пресет существует только в API␍
и UI: он заполняет флаги при создании и полем модели не является.␍
␍
Инварианты выражают одно правило `ADR-HUB-0019`: непродуктовый канал не␍
формирует коммерческих действий. Проверяются по итоговому состоянию, а не по␍
Источник истины — булевы поля `Channel`. Продуктовой идентичности больше нет␍
(ADR-HUB-0045: сущность `Product` удалена), поэтому коммерческие действия и␍
attribution запрещены безусловно. Проверяются по итоговому состоянию, а не по␍
переданным полям, — частичное применение запрещено.␍
"""␍
␍
@@ -17,7 +15,6 @@ from django.core.exceptions import ValidationError
from chatballs.channels.models import Channel␍
␍
POLICY_FIELDS = (␍
"requires_authenticated_product_identity",␍
"allow_anonymous_sessions",␍
"allow_self_reported_contact",␍
"allow_sales_attribution",␍
@@ -26,7 +23,6 @@ POLICY_FIELDS = (
␍
# Ключ payload -> имя поля модели (SPEC §6.1).␍
POLICY_API_FIELDS = {␍
"requiresAuthenticatedProductIdentity": "requires_authenticated_product_identity",␍
"allowAnonymousSessions": "allow_anonymous_sessions",␍
"allowSelfReportedContact": "allow_self_reported_contact",␍
"allowSalesAttribution": "allow_sales_attribution",␍
@@ -34,15 +30,8 @@ POLICY_API_FIELDS = {
}␍
␍
␍
class PolicyPreset:␍
SALES = "SALES"␍
SUPPORT = "SUPPORT"␍
CUSTOM = "CUSTOM"␍
␍
␍
@dataclass(frozen=True, slots=True)␍
class ChannelPolicy:␍
requires_authenticated_product_identity: bool␍
allow_anonymous_sessions: bool␍
allow_self_reported_contact: bool␍
allow_sales_attribution: bool␍
@@ -52,10 +41,6 @@ class ChannelPolicy:
def from_channel(cls, channel: Channel) -> ChannelPolicy:␍
return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS})␍
␍
@classmethod␍
def from_preset(cls, preset: str) -> ChannelPolicy:␍
return _PRESETS[preset]␍
␍
def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy:␍
return replace(self, **changes)␍
␍
@@ -66,26 +51,6 @@ class ChannelPolicy:
return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()}␍
␍
␍
# SPEC §3.3. SALES и SUPPORT требуют продукта — иначе нарушают P1-P3.␍
_PRESETS = {␍
PolicyPreset.SALES: ChannelPolicy(␍
requires_authenticated_product_identity=False,␍
allow_anonymous_sessions=True,␍
allow_self_reported_contact=True,␍
allow_sales_attribution=True,␍
allow_checkout_actions=True,␍
),␍
# Комбинация support-канала из SPEC-HUB-0010 §4.2.␍
PolicyPreset.SUPPORT: ChannelPolicy(␍
requires_authenticated_product_identity=True,␍
allow_anonymous_sessions=False,␍
allow_self_reported_contact=False,␍
allow_sales_attribution=False,␍
allow_checkout_actions=False,␍
),␍
}␍
␍
␍
@dataclass(frozen=True, slots=True)␍
class PolicyViolation:␍
rule: str␍
@@ -96,58 +61,30 @@ class PolicyViolation:
return {"rule": self.rule, "field": self.field, "detail": self.detail}␍
␍
␍
def policy_violations(␍
*, policy: ChannelPolicy, has_product: bool␍
) -> tuple[PolicyViolation, ...]:␍
"""Нарушения P1-P5 для итогового состояния канала (SPEC §3.2)."""␍
def policy_violations(*, policy: ChannelPolicy) -> tuple[PolicyViolation, ...]:␍
"""Нарушения P1-P2 для итогового состояния канала (SPEC §3.2)."""␍
violations: list[PolicyViolation] = []␍
if not has_product:␍
if policy.allow_checkout_actions:␍
violations.append(␍
PolicyViolation(␍
"P1",␍
"allowCheckoutActions",␍
"Коммерческие действия недоступны непродуктовому каналу",␍
)␍
if policy.allow_checkout_actions:␍
violations.append(␍
PolicyViolation(␍
"P1",␍
"allowCheckoutActions",␍
"Коммерческие действия недоступны",␍
)␍
if policy.allow_sales_attribution:␍
violations.append(␍
PolicyViolation(␍
"P2",␍
"allowSalesAttribution",␍
"Attribution недоступна непродуктовому каналу",␍
)␍
)␍
if policy.requires_authenticated_product_identity:␍
violations.append(␍
PolicyViolation(␍
"P3",␍
"requiresAuthenticatedProductIdentity",␍
"Продуктовая идентичность требует продукта",␍
)␍
)␍
if policy.requires_authenticated_product_identity:␍
if policy.allow_anonymous_sessions:␍
violations.append(␍
PolicyViolation(␍
"P4",␍
"allowAnonymousSessions",␍
"Анонимные сессии несовместимы с обязательной идентичностью",␍
)␍
)␍
if policy.allow_self_reported_contact:␍
violations.append(␍
PolicyViolation(␍
"P5",␍
"allowSelfReportedContact",␍
"Самозаявленный контакт несовместим с обязательной идентичностью",␍
)␍
)␍
if policy.allow_sales_attribution:␍
violations.append(␍
PolicyViolation(␍
"P2",␍
"allowSalesAttribution",␍
"Attribution недоступна",␍
)␍
)␍
return tuple(violations)␍
␍
␍
def require_valid_policy(*, policy: ChannelPolicy, has_product: bool) -> None:␍
violations = policy_violations(policy=policy, has_product=has_product)␍
def require_valid_policy(*, policy: ChannelPolicy) -> None:␍
violations = policy_violations(policy=policy)␍
if violations:␍
raise PolicyInvariantError(violations)␍
␍
+2 -2
View File
@@ -9,7 +9,7 @@ from chatballs.tenancy.context import TenantContext
def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]:
return queryset.select_related(
"product", "group", "ai_agent", "ai_agent__provider_integration"
"group", "ai_agent", "ai_agent__provider_integration"
).prefetch_related(
Prefetch("connections", queryset=Integration.objects.order_by("id"))
).annotate(
@@ -32,7 +32,7 @@ def channels_in_organization(context: TenantContext) -> QuerySet[Channel]:
"""
return (
Channel.objects.filter(organization_id=context.organization_id)
.select_related("product", "group", "ai_agent", "ai_agent__provider_integration")
.select_related("group", "ai_agent", "ai_agent__provider_integration")
.order_by("name")
)
+2 -29
View File
@@ -13,7 +13,6 @@ from chatballs.channels.models import Channel
from chatballs.channels.policy import ChannelPolicy, require_valid_policy
from chatballs.identity.group_models import EmployeeGroup
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.products.models import Product
from chatballs.tenancy.context import TenantContext
# SPEC §3.1: slug 1-64, входит в embed-URL web-виджета и после создания immutable.
@@ -68,7 +67,6 @@ class ChannelUpdate:
name: Any = UNSET
group_id: Any = UNSET
product_id: Any = UNSET
is_active: Any = UNSET
policy: dict[str, bool] = field(default_factory=dict)
@@ -96,19 +94,6 @@ def _group_for_channel(
raise ValidationError({"groupId": "Unknown group"}) from error
def _product_for_channel(
*, context: TenantContext, product_id: int | None
) -> Product | None:
if product_id is None:
return None
try:
return Product.objects.get(
id=product_id, organization_id=context.organization_id
)
except Product.DoesNotExist as error:
raise ValidationError({"productId": "Unknown product"}) from error
@transaction.atomic
def update_channel(
*, context: TenantContext, channel: Channel, update: ChannelUpdate
@@ -117,8 +102,7 @@ def update_channel(
состояние, инварианты, конфликты, одна транзакция.
Инварианты проверяются по итоговому состоянию, а не по переданным полям:
выключить продукт и коммерческие флаги можно одним запросом, а вот запрос,
оставляющий канал в запрещённой комбинации, отклоняется целиком.
запрос, оставляющий канал в запрещённой комбинации, отклоняется целиком.
"""
locked = Channel.objects.select_for_update().get(
id=channel.id, organization_id=context.organization_id
@@ -128,10 +112,6 @@ def update_channel(
authorization.require_channel_manage(context)
if update.group_id is not UNSET and update.group_id != locked.group_id:
authorization.require_channel_manage(context)
if update.product_id is not UNSET and update.product_id != locked.product_id:
authorization.require_organization_manage(
context, operation="Изменение продукта канала"
)
if update.is_active is not UNSET and update.is_active != locked.is_active:
authorization.require_organization_manage(
context, operation="Изменение статуса канала"
@@ -150,10 +130,6 @@ def update_channel(
if update.group_id is not UNSET and update.group_id != locked.group_id:
locked.group = _group_for_channel(context=context, group_id=update.group_id)
changed.append("group")
if update.product_id is not UNSET and update.product_id != locked.product_id:
product = _product_for_channel(context=context, product_id=update.product_id)
locked.product = product
changed.append("product")
if update.is_active is not UNSET and update.is_active != locked.is_active:
locked.is_active = bool(update.is_active)
changed.append("is_active")
@@ -164,10 +140,7 @@ def update_channel(
# Инварианты по целевому состоянию — до записи: нарушение отклоняет запрос
# целиком, частичного применения не остаётся даже в памяти (§3.2, §6.5).
require_valid_policy(
policy=ChannelPolicy.from_channel(locked),
has_product=locked.product_id is not None,
)
require_valid_policy(policy=ChannelPolicy.from_channel(locked))
if changed:
locked.save(update_fields=[*changed, "updated_at"])
@@ -1,188 +1,59 @@
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P2.
Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
данных, поэтому она проверяется напрямую на реальном реестре моделей — это
честнее, чем поднимать историческое состояние, где `identity` откатывается
рассинхронно со схемой БД.
"""
import importlib
from django.apps import apps
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.identity.models import Organization
from chatballs.products.models import Product
migration = importlib.import_module(
"chatballs.channels.migrations.0005_enforce_policy_invariants"
)
class PolicyInvariantMigrationTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Acme", slug="acme")
self.product = Product.objects.create(
organization=self.organization, code="app", name="Acme"
)
def _channel(self, code: str, **fields) -> Channel:
return Channel.objects.create(
organization=self.organization, code=code, name=code, **fields
)
def _run(self) -> None:
migration.relax_non_product_channels(apps, None)
def test_clears_commercial_flags_on_non_product_channels(self) -> None:
# Ровно случай канала acme: непродуктовый, но с checkout и attribution.
def test_clears_commercial_flags(self) -> None:
# Ровно случай канала acme: checkout и attribution включены.
violating = self._channel(
"acme", allow_checkout_actions=True, allow_sales_attribution=True
)
self._run()
violating.refresh_from_db()
self.assertFalse(violating.allow_checkout_actions)
self.assertFalse(violating.allow_sales_attribution)
# Остальные флаги непродуктового канала не трогаются.
# Остальные флаги канала не трогаются.
self.assertTrue(violating.allow_anonymous_sessions)
self.assertTrue(violating.allow_self_reported_contact)
def test_leaves_product_channels_untouched(self) -> None:
product_channel = self._channel(
"app-sales",
product=self.product,
allow_checkout_actions=True,
allow_sales_attribution=True,
)
self._run()
product_channel.refresh_from_db()
self.assertTrue(product_channel.allow_checkout_actions)
self.assertTrue(product_channel.allow_sales_attribution)
def test_stops_the_rollout_on_p3_p5_violations(self) -> None:
# Обязательная идентичность без продукта — смена смысла канала,
# выбирать за владельца нельзя, поэтому выкат останавливается.
self._channel("broken", requires_authenticated_product_identity=True)
untouched = self._channel(
"acme", allow_checkout_actions=True, allow_sales_attribution=True
)
with self.assertRaises(RuntimeError) as error:
self._run()
self.assertIn("broken", str(error.exception))
untouched.refresh_from_db()
self.assertTrue(untouched.allow_checkout_actions)
def test_is_idempotent(self) -> None:
self._channel("acme", allow_checkout_actions=True)
self._run()
self._run()
self.assertEqual(
Channel.objects.filter(
product__isnull=True, allow_checkout_actions=True
).count(),
Channel.objects.filter(allow_checkout_actions=True).count(),
0,
)
@@ -53,7 +53,7 @@ def _mode(latest: Conversation) -> str:
def clients_overview(organization_id: int) -> list[dict]:
conversation_qs = Conversation.objects.select_related(
"channel", "channel__product", "connection", "assigned_operator"
"channel", "connection", "assigned_operator"
).order_by("-last_activity_at")
identity_qs = ConnectionIdentity.objects.select_related("connection")
contacts = Contact.objects.filter(organization_id=organization_id, merged_into__isnull=True).prefetch_related(
@@ -69,15 +69,12 @@ def clients_overview(organization_id: int) -> list[dict]:
if not conversations:
continue # клиенты — те, кто писал
channels: set[str] = set()
products: dict[str, str] = {}
agents: dict[int, dict[str, object]] = {}
open_dialogs = 0
for conversation in conversations:
provider = conversation.connection.provider if conversation.connection_id else None
if provider in PROVIDER_CODE:
channels.add(PROVIDER_CODE[provider])
if conversation.channel.product_id:
products[conversation.channel.product.code] = conversation.channel.product.name
# Агент = карточка канала обработки: по нему фильтруется список (кадр K1).
agents.setdefault(
conversation.channel_id,
@@ -104,7 +101,6 @@ def clients_overview(organization_id: int) -> list[dict]:
# Первый непустой @логин среди identity каналов (остальные — в карточке).
"username": next((identity.username for identity in contact.identities.all() if identity.username), ""),
"channels": sorted(channels),
"products": [{"code": code, "name": name} for code, name in sorted(products.items())],
"openDialogs": open_dialogs,
"totalDialogs": len(conversations),
"lastActivityAt": latest.last_activity_at.isoformat(),
@@ -128,21 +124,18 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
contact = Contact.objects.get(organization_id=organization_id, id=contact_id)
conversation_qs = Conversation.objects.filter(
organization_id=organization_id, contact=contact
).select_related("channel", "channel__product", "connection", "group", "assigned_operator", "note_author")
).select_related("channel", "connection", "group", "assigned_operator", "note_author")
conversations = list(conversation_qs.order_by("-last_activity_at"))
if not conversations:
raise Contact.DoesNotExist
channels: set[str] = set()
products: dict[str, str] = {}
open_dialogs = 0
dialogs: list[dict] = []
for conversation in conversations:
provider = conversation.connection.provider if conversation.connection_id else None
if provider in PROVIDER_CODE:
channels.add(PROVIDER_CODE[provider])
if conversation.channel.product_id:
products[conversation.channel.product.code] = conversation.channel.product.name
if conversation.lifecycle == LifecycleState.OPEN:
open_dialogs += 1
# Тема диалога — первое сообщение, превью — последнее (кадр K4).
@@ -240,7 +233,6 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
"",
),
"channels": sorted(channels),
"products": [{"code": code, "name": name} for code, name in sorted(products.items())],
"openDialogs": open_dialogs,
"totalDialogs": len(conversations),
"firstContactAt": contact.created_at.isoformat(),
@@ -12,16 +12,10 @@ class Migration(migrations.Migration):
('conversations', '0001_initial'),
('identity', '0006_alter_employeeprofile_totp_secret'),
('integrations', '0002_integration_channel_integration_poll_marker'),
('support', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='conversation',
name='support_identity_snapshot',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='conversations', to='support.supportidentitysnapshot'),
),
migrations.AlterField(
model_name='conversation',
name='contact',
@@ -29,6 +23,6 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='conversation',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('contact__isnull', True), ('support_identity_snapshot__isnull', False)), models.Q(('contact__isnull', False), ('support_identity_snapshot__isnull', True)), _connector='OR'), name='conversation_exactly_one_identity'),
constraint=models.CheckConstraint(condition=models.Q(('contact__isnull', False)), name='conversation_requires_contact'),
),
]
+6 -16
View File
@@ -147,17 +147,10 @@ class Conversation(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="conversations")
channel = models.ForeignKey("channels.Channel", on_delete=models.PROTECT, related_name="conversations")
connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="conversations", null=True, blank=True)
# Источник identity диалога: sales Contact (лид/аноним) ИЛИ verified
# SupportIdentitySnapshot (authenticated клиент продукта). ADR-HUB-0022:
# sales-identity и support-identity разделены, ровно один источник на диалог.
# Единственный источник identity диалога — контакт. Авторизованный
# in-product клиент (SupportIdentitySnapshot) удалён вместе с сущностью
# Product (ADR-HUB-0045).
contact = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="conversations", null=True, blank=True)
support_identity_snapshot = models.ForeignKey(
"support.SupportIdentitySnapshot",
on_delete=models.PROTECT,
related_name="conversations",
null=True,
blank=True,
)
# Внешний идентификатор чата (для отправки ответа в канал).
external_chat_id = models.CharField(max_length=128, blank=True)
# Транспортная мета диалога (ADR-HUB-0035): для email — тема исходного
@@ -204,13 +197,10 @@ class Conversation(models.Model):
ordering = ["-last_activity_at"]
indexes = [models.Index(fields=["channel", "lifecycle"])]
constraints = [
# Ровно один источник identity: sales Contact XOR SupportIdentitySnapshot.
# Диалог всегда принадлежит контакту.
models.CheckConstraint(
condition=(
models.Q(contact__isnull=True, support_identity_snapshot__isnull=False)
| models.Q(contact__isnull=False, support_identity_snapshot__isnull=True)
),
name="conversation_exactly_one_identity",
condition=models.Q(contact__isnull=False),
name="conversation_requires_contact",
),
]
@@ -10,11 +10,9 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
Conversation.objects.filter(organization_id=context.organization_id)
.select_related(
"channel",
"channel__product",
"contact",
"connection",
"assigned_operator",
"support_identity_snapshot",
"group",
)
.prefetch_related("labels")
@@ -81,27 +81,6 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
}
def _support_identity_snapshot(
conversation: Conversation, *, detailed: bool = False
) -> dict[str, object] | None:
snapshot = conversation.support_identity_snapshot
if snapshot is None:
return None
# Краткая карточка для списка диалогов; в detail-режиме — operator_context_json
# + account_key для правой панели оператора (ADR-HUB-0022 §8.3: рендер по контракту).
payload: dict[str, object] = {
"id": snapshot.id,
"subjectKey": snapshot.subject_key,
"displayName": snapshot.display_name,
"displayEmail": snapshot.display_email,
"contractCode": snapshot.contract_code,
}
if detailed:
payload["accountKey"] = snapshot.account_key
payload["operatorContextJson"] = snapshot.operator_context_json
return payload
def _contact_username(conversation: Conversation) -> str:
# Username живёт на identity подключения (у контакта их может быть несколько).
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
@@ -123,14 +102,8 @@ def _contact_email(conversation: Conversation) -> str:
def _conversation_history(conversation: Conversation) -> list[Conversation]:
# История по тому же источнику identity: для sales — по contact, для
# support — по snapshot (ADR-HUB-0002: цепочка прошлых обращений).
if conversation.support_identity_snapshot_id:
qs = Conversation.objects.filter(
support_identity_snapshot_id=conversation.support_identity_snapshot_id
)
else:
qs = Conversation.objects.filter(contact_id=conversation.contact_id)
# Цепочка прошлых обращений того же контакта (ADR-HUB-0002).
qs = Conversation.objects.filter(contact_id=conversation.contact_id)
return list(
qs.exclude(id=conversation.id)
.select_related("channel", "connection", "assigned_operator")
@@ -153,7 +126,6 @@ def conversation_payload(
"id": channel.id,
"code": channel.code,
"name": channel.name,
"product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None,
},
"connection": (
{
@@ -166,8 +138,6 @@ def conversation_payload(
if conversation.connection_id
else None
),
# Источник identity: sales Contact ИЛИ verified SupportIdentitySnapshot.
# Для support-диалогов contact=None, клиент представлен snapshot'ом.
"contact": (
{
"id": conversation.contact_id,
@@ -183,7 +153,6 @@ def conversation_payload(
if conversation.contact_id
else None
),
"supportIdentitySnapshot": _support_identity_snapshot(conversation, detailed=with_messages),
"lifecycle": conversation.lifecycle,
"controlMode": conversation.control_mode,
"expectedResponder": conversation.expected_responder,
+2 -18
View File
@@ -112,36 +112,21 @@ def sales_overview_stats(context, period: str) -> dict:
period_by_channel = dict(period_qs.values_list("channel_id").annotate(c=Count("id")))
by_channel: list[dict] = []
by_product: dict[str, dict] = {}
channels = channels_in_organization(context)
for channel in channels:
open_count = open_by_channel.get(channel.id, 0)
period_count = period_by_channel.get(channel.id, 0)
by_channel.append({"code": channel.code, "name": channel.name, "openDialogs": open_count, "dialogs": period_count})
if channel.product_id:
bucket = by_product.setdefault(
channel.product.code,
{"code": channel.product.code, "name": channel.product.name, "openDialogs": 0, "dialogs": 0},
)
bucket["openDialogs"] += open_count
bucket["dialogs"] += period_count
problems = []
for conversation in (
open_qs.filter(expected_responder=ExpectedResponder.OPERATOR)
.select_related("contact", "support_identity_snapshot", "channel", "channel__product")
.select_related("contact", "channel")
.order_by("last_activity_at")[:5]
):
minutes = int((now - conversation.last_activity_at).total_seconds() // 60)
meta = conversation.channel.name
# Имя клиента: sales Contact.name либо display_name support-снапшота.
snapshot = conversation.support_identity_snapshot
if conversation.contact_id:
title = conversation.contact.name or "Гость"
elif snapshot is not None:
title = snapshot.display_name or f"client:{snapshot.subject_key[:8]}"
else:
title = "Гость"
title = (conversation.contact.name if conversation.contact_id else "") or "Гость"
problems.append(
{
"conversationId": conversation.id,
@@ -156,7 +141,6 @@ def sales_overview_stats(context, period: str) -> dict:
"ops": ops,
"period": period_block,
"byChannel": by_channel,
"byProduct": list(by_product.values()),
"chart": _chart(organization_id, period, start, now),
"problems": problems,
}
@@ -79,7 +79,6 @@ class ConversationListView(ConversationViewBase):
)
items = items.filter(
Q(contact__name__icontains=query)
| Q(support_identity_snapshot__display_name__icontains=query)
| Q(Exists(message_match))
)
items = list(items)
@@ -21,10 +21,6 @@ AUDIT_ACTION_LABELS = {
"administration.organization_updated": "Изменены данные организации",
"administration.logo_updated": "Изменён логотип организации",
"administration.logo_deleted": "Удалён логотип организации",
"products.product_created": "Добавлен продукт",
"products.product_updated": "Изменён продукт",
"products.product_active": "Продукт включён",
"products.product_disabled": "Продукт отключён",
"integrations.integration_created": "Добавлена интеграция",
"integrations.integration_updated": "Изменена интеграция",
"integrations.integration_deleted": "Удалена интеграция",
@@ -10,7 +10,6 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.products.models import Product
@dataclass(frozen=True)
@@ -43,8 +42,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
support_group, _ = EmployeeGroup.objects.get_or_create(
organization=organization, name="Поддержка"
)
for code, name in (("site", "Сайт"), ("app", "Приложение")):
Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name})
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов.
owner, created_owner = HumanUser.objects.get_or_create(
@@ -12,8 +12,6 @@ ALL_CAPABILITIES: frozenset[str] = frozenset(
"employees.view",
"employees.manage",
"groups.manage",
"products.view",
"products.manage",
"channels.view",
"channels.manage",
"ai.view",
@@ -1,7 +0,0 @@
{
"schemaVersion": 1,
"_comment": "Product — скрытая техническая ось (ADR-HUB-0041): нужен порталу поддержки и веб-виджету.",
"products": [
{"code": "atelie-nord-site", "name": "Сайт ателье", "siteUrl": "https://atelie-nord.ru"}
]
}
@@ -56,7 +56,6 @@
"limits": {},
"createdDaysAgo": 30,
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true
}
@@ -67,9 +66,9 @@
"name": "Личный кабинет",
"group": "support",
"aiStatus": "ACTIVE",
"persona": "Ты — помощник личного кабинета ателье «Норд»: заказы, оплата, статусы, доставка, возвраты. Клиент авторизован — его заказы известны.",
"persona": "Ты — помощник раздела «Личный кабинет» ателье «Норд»: заказы, оплата, статусы, доставка, возвраты.",
"tone": "Спокойно и по делу.",
"instructions": "Опирайся на данные заказа клиента и знания о кабинете. Жалобы на оплату — передай сотруднику.",
"instructions": "Опирайся на знания о кабинете и заказах. Номер заказа спрашивай, если его не назвали. Жалобы на оплату — передай сотруднику.",
"knowledge": [
"account",
"delivery",
@@ -82,11 +81,9 @@
"limits": {},
"createdDaysAgo": 28,
"policy": {
"requires_authenticated_product_identity": true,
"allow_anonymous_sessions": false,
"allow_self_reported_contact": false
},
"product": "atelie-nord-site"
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true
}
},
{
"key": "reception",
@@ -866,7 +866,6 @@
"connection": "web_account",
"contact": "sergey",
"identity": "sergey_web",
"supportIdentitySnapshot": "sergey_account",
"externalChatId": "portal-sergey-1",
"lifecycle": "CLOSED",
"controlMode": "HUMAN",
@@ -1,55 +1,11 @@
{
"schemaVersion": 1,
"contracts": [
{
"key": "site_contract",
"code": "SITE-2026",
"product": "atelie-nord-site",
"version": 1,
"status": "ACTIVE"
}
],
"identitySnapshots": [
{
"key": "sergey_account",
"contract": "site_contract",
"subjectKey": "user:4402",
"accountKey": "acc-4402",
"displayName": "Сергей Крылов",
"displayEmail": "s.krylov@example.ru",
"payload": {
"orders": [
{
"number": "4402",
"status": "В производстве",
"total": 12480
}
],
"city": "Казань"
},
"operatorContext": {
"Клиент с": "апрель 2026",
"Заказов": "2",
"Последний заказ": "4402 — комплект штор, 12 480 ₽"
},
"aiContext": {
"orders": "4402 in production"
},
"searchText": "Сергей Крылов s.krylov@example.ru 4402 Казань",
"issuedDaysAgo": 6,
"expiresInDays": 24
}
],
"portal": {
"slug": "atelie-nord",
"name": "Помощь · Ателье Норд",
"locale": "ru",
"status": "PUBLISHED",
"widgetConnection": "web_help",
"accountWidgetConnection": "web_account",
"products": [
"atelie-nord-site"
],
"categories": [
{
"slug": "zakaz",
@@ -87,7 +43,9 @@
"helpfulVotes": 14,
"unhelpfulVotes": 1,
"publishedDaysAgo": 27,
"files": ["katalog-tkanej-2026.pdf"]
"files": [
"katalog-tkanej-2026.pdf"
]
},
{
"slug": "sroki-dostavki",
@@ -1,25 +0,0 @@
"""Каталог: продукты (скрытая техническая привязка, ADR-HUB-0041)."""
from __future__ import annotations
from chatballs.identity.demo_seed import manifest
from chatballs.identity.demo_seed.refs import DemoRefs
from chatballs.products.models import Product, ProductStatus
from chatballs.tenancy.context import TenantContext
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("catalog")
organization = refs.organization
for item in data["products"]:
product, _ = Product.objects.get_or_create(
organization=organization,
code=item["code"],
defaults={
"name": item["name"],
"status": ProductStatus.ACTIVE,
"site_url": item.get("siteUrl", ""),
},
)
refs.products[item["code"]] = product
@@ -72,7 +72,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
defaults={
"name": item["name"],
"group": refs.groups.get(item.get("group")),
"product": refs.products.get(item.get("product")),
"is_active": item.get("isActive", True),
**item.get("policy", {}),
},
@@ -250,7 +249,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
failures_left -= 1
invocation = LlmInvocation.objects.create(
channel=channel,
product=channel.product,
purpose="agent_chat",
operation="chat",
model=spec["model"],
@@ -269,7 +267,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
tokens = random.randint(200, 900)
invocation = LlmInvocation.objects.create(
channel=channel,
product=None,
purpose="knowledge_index",
operation="embedding",
model="text-embedding-3-small",
@@ -164,14 +164,11 @@ def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, cur
refs.conversations[item["key"]] = conversation
return
snapshot = refs.identity_snapshots.get(item.get("supportIdentitySnapshot"))
conversation = Conversation.objects.create(
organization=organization,
channel=channel,
connection=connection,
# Ровно одна личность: снимок из личного кабинета либо контакт.
contact=None if snapshot is not None else contact,
support_identity_snapshot=snapshot,
contact=contact,
external_chat_id=external_chat_id,
transport_meta=item.get("transportMeta", {}),
lifecycle=item.get("lifecycle", "OPEN"),
@@ -1,5 +1,5 @@
"""Поддержка: контракты, снимки личности, портал помощи, статьи (опубликованные,
черновик, архив, вторая ревизия), оценки читателей, статьи у агентов."""
"""Поддержка: портал помощи, статьи (опубликованные, черновик, архив, вторая
ревизия), оценки читателей, статьи у агентов."""
from __future__ import annotations
@@ -10,11 +10,6 @@ from django.core.files.base import ContentFile
from chatballs.identity.demo_seed import manifest
from chatballs.identity.demo_seed.loaders.common import backdate, now
from chatballs.identity.demo_seed.refs import DemoRefs
from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
from chatballs.support_portals.content_services import (
add_revision,
archive_article,
@@ -31,7 +26,6 @@ from chatballs.support_portals.models import (
from chatballs.support_portals.portal_services import (
PortalInput,
create_portal,
replace_product_links,
set_portal_status,
)
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
@@ -42,53 +36,10 @@ from chatballs.tenancy.storage import adjust_storage_usage
def load(context: TenantContext, refs: DemoRefs) -> None:
data = manifest.load("support")
current = now()
_ensure_contracts(refs, data.get("contracts", []))
_ensure_snapshots(refs, data.get("identitySnapshots", []), current)
_ensure_portal(context, refs, data.get("portal"), current)
_link_agent_articles(refs)
def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None:
for item in items:
contract, _ = ProductSupportContract.objects.get_or_create(
organization=refs.organization,
code=item["code"],
defaults={
"product": refs.products[item["product"]],
"version": item["version"],
"status": item.get("status", ContractStatus.ACTIVE),
},
)
support_channel = refs.channels.get("support")
if support_channel is not None:
contract.allowed_channels.add(support_channel)
refs.support_contracts[item["key"]] = contract
def _ensure_snapshots(refs: DemoRefs, items: list[dict], current) -> None:
for item in items:
contract = refs.support_contracts[item["contract"]]
snapshot, _ = SupportIdentitySnapshot.objects.get_or_create(
organization=refs.organization,
contract=contract,
subject_key=item["subjectKey"],
defaults={
"product": contract.product,
"contract_code": contract.code,
"account_key": item.get("accountKey"),
"display_name": item.get("displayName", ""),
"display_email": item.get("displayEmail", ""),
"payload_json": item.get("payload", {}),
"operator_context_json": item.get("operatorContext", {}),
"ai_context_json": item.get("aiContext", {}),
"search_text": item.get("searchText", ""),
"token_issued_at": current - timedelta(days=item.get("issuedDaysAgo", 1)),
"token_expires_at": current + timedelta(days=item.get("expiresInDays", 30)),
},
)
refs.identity_snapshots[item["key"]] = snapshot
def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None, current) -> None:
if not portal_data:
return
@@ -105,20 +56,6 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N
),
)
backdate(portal, current - timedelta(days=28), "created_at")
# Виджет портала — анонимный (чат на странице помощи); виджет продукта —
# авторизованный (личный кабинет), через него идут обращения с личностью.
account_widget = refs.widgets.get(portal_data.get("accountWidgetConnection"))
links = []
for product_code in portal_data.get("products", []):
product = refs.products[product_code]
links.append(
{
"productId": product.id,
"supportWidgetId": account_widget.id if account_widget is not None else None,
}
)
if links:
replace_product_links(context=context, portal=portal, links=links)
categories: dict[str, PortalCategory] = {}
for item in portal_data.get("categories", []):
@@ -21,7 +21,6 @@ SCHEMA_VERSION = 1
#: Файлы манифестов по доменам (имя файла без расширения).
MANIFEST_FILES: tuple[str, ...] = (
"organization",
"catalog",
"channels_ai",
"conversations",
"support",
@@ -2,15 +2,12 @@
Создаёт полный, взаимосвязанный набор выдуманных данных одной организации,
будто система уже работает. Запускается строго в порядке FK-зависимостей:
foundation → catalog → channels/ai → support → conversations → operations.
foundation → channels/ai → support → conversations → operations.
Идемпотентен на каждом шаге.
"""
from __future__ import annotations
from chatballs.identity.demo_seed.loaders import (
catalog as catalog_loader,
)
from chatballs.identity.demo_seed.loaders import (
channels_ai as channels_ai_loader,
)
@@ -47,10 +44,8 @@ def run_demo_seed(
refs.recorder = recorder
foundation_loader.load(context, refs)
catalog_loader.load(context, refs)
channels_ai_loader.load(context, refs)
# Поддержка раньше диалогов: диалог портала ссылается на снимок личности,
# а статьи портала привязываются к агентам.
# Поддержка раньше диалогов: статьи портала привязываются к агентам.
support_loader.load(context, refs)
conversations_loader.load(context, refs)
operations_loader.load(context, refs)
@@ -22,7 +22,6 @@ class DemoRefs:
groups: dict[str, object] = field(default_factory=dict)
memberships: dict[str, object] = field(default_factory=dict)
users: dict[str, object] = field(default_factory=dict)
products: dict[str, object] = field(default_factory=dict)
integrations: dict[str, object] = field(default_factory=dict)
channels: dict[str, object] = field(default_factory=dict)
agents: dict[str, object] = field(default_factory=dict)
@@ -35,7 +34,5 @@ class DemoRefs:
contacts: dict[str, object] = field(default_factory=dict)
identities: dict[str, object] = field(default_factory=dict)
conversations: dict[str, object] = field(default_factory=dict)
support_contracts: dict[str, object] = field(default_factory=dict)
identity_snapshots: dict[str, object] = field(default_factory=dict)
calls: dict[str, object] = field(default_factory=dict)
portal: object | None = None
@@ -135,28 +135,6 @@ class Migration(migrations.Migration):
),
],
),
migrations.CreateModel(
name="Product",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("code", models.SlugField(max_length=64)),
("name", models.CharField(max_length=255)),
(
"status",
models.CharField(choices=[("ACTIVE", "Active"), ("DISABLED", "Disabled")], default="ACTIVE", max_length=32),
),
("site_url", models.URLField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="products",
to="identity.organization",
),
),
],
),
migrations.CreateModel(
name="EmployeeProfile",
fields=[
@@ -211,8 +189,4 @@ class Migration(migrations.Migration):
model_name="department",
constraint=models.UniqueConstraint(fields=("organization", "code"), name="uniq_department_org_code"),
),
migrations.AddConstraint(
model_name="product",
constraint=models.UniqueConstraint(fields=("organization", "code"), name="uniq_product_org_code"),
),
]
@@ -1,15 +1,11 @@
# ADR-HUB-0045: сущность Product удалена целиком. Миграция сохранена пустой —
# на неё ссылаются следующие миграции identity.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("identity", "0004_employeeprofile_phone"),
("products", "0001_move_product_state"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[migrations.DeleteModel(name="Product")],
)
]
operations = []
@@ -34,7 +34,6 @@ class Migration(migrations.Migration):
('channels', '0006_remove_channel_department'),
('identity', '0019_drop_sales_capabilities'),
('notifications', '0008_remove_notification_department'),
('products', '0012_delete_productdepartment'),
('support_portals', '0008_remove_supportportal_department'),
]
@@ -181,9 +181,9 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
def test_audit_returns_product_text_instead_of_internal_action_codes(self) -> None:
def test_audit_returns_readable_text_instead_of_internal_action_codes(self) -> None:
record_audit_event(
action="products.product_created",
action="administration.organization_updated",
actor=self.owner,
organization=self.organization,
)
@@ -192,8 +192,8 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 200)
event = response.json()["items"][0]
self.assertEqual(event["action"], "Добавлен продукт")
self.assertNotIn("products.", event["action"])
self.assertEqual(event["action"], "Изменены данные организации")
self.assertNotIn("administration.", event["action"])
class InstanceAddressTests(TestCase):
+1 -51
View File
@@ -22,7 +22,6 @@ from chatballs.identity.models import (
OrganizationMembership,
)
from chatballs.identity.policy import ResourceScope, authorize
from chatballs.products.models import Product
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@@ -43,7 +42,6 @@ class BootstrapOwnerTests(TestCase):
{"Операторы", "Поддержка"},
)
self.assertEqual(result.support_group.name, "Поддержка")
self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"site", "app"})
self.assertEqual(result.owner.memberships.get().role, EmployeeRole.OWNER)
# TOTP выключен по умолчанию (намеренно, локальная разработка).
self.assertFalse(result.owner.memberships.get().totp_required)
@@ -67,7 +65,6 @@ class BootstrapOwnerTests(TestCase):
self.assertFalse(second.created_owner)
self.assertEqual(HumanUser.objects.count(), 2)
self.assertEqual(Organization.objects.count(), 1)
self.assertEqual(Product.objects.count(), 2)
def test_bootstrap_promotes_existing_owner_to_django_admin_access(self) -> None:
user = HumanUser.objects.create_user(email="owner@example.com", password="temporary-password")
@@ -595,55 +592,14 @@ class CompanyEndpointTests(TestCase):
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def test_owner_reads_groups_and_products(self) -> None:
def test_owner_reads_groups(self) -> None:
groups_response = self.client.get("/api/v1/company/groups/")
products_response = self.client.get("/api/v1/company/products/")
self.assertEqual(groups_response.status_code, 200)
self.assertEqual(products_response.status_code, 200)
self.assertEqual(
set(group["name"] for group in groups_response.json()["items"]),
{"Операторы", "Поддержка"},
)
self.assertEqual(
set(product["code"] for product in products_response.json()["items"]),
{"site", "app"},
)
def test_owner_creates_and_deactivates_product(self) -> None:
create_response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(create_response.status_code, 201)
product_id = create_response.json()["product"]["id"]
deactivate_response = self.client.post(f"/api/v1/company/products/{product_id}/deactivate/")
self.assertEqual(deactivate_response.status_code, 200)
self.assertEqual(deactivate_response.json()["product"]["status"], "DISABLED")
self.assertTrue(AuditEvent.objects.filter(action="products.product_created").exists())
self.assertTrue(AuditEvent.objects.filter(action="products.product_disabled").exists())
def test_operator_cannot_create_product(self) -> None:
operator = HumanUser.objects.create_user(email="operator@example.com", password="operator-password")
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
)
self.client.logout()
self.client.login(username="operator@example.com", password="operator-password")
response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
class TotpSecretEncryptionTests(TestCase):
@@ -945,12 +901,6 @@ class EmployeeGovernanceTests(TestCase):
client = self._client("admin@example.com")
audit = client.get("/api/v1/company/administration/audit/")
self.assertEqual(audit.status_code, 200)
product = client.post(
"/api/v1/company/products/create/",
data=json.dumps({"code": "academy", "name": "Academy"}),
content_type="application/json",
)
self.assertEqual(product.status_code, 201)
def test_employee_denied_audit(self) -> None:
self._make("emp@example.com", EmployeeRole.EMPLOYEE)
@@ -1 +0,0 @@
-10
View File
@@ -1,10 +0,0 @@
from django.contrib import admin
from chatballs.products.models import Product
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ["code", "name", "organization", "status", "site_url"]
list_filter = ["organization", "status"]
search_fields = ["code", "name"]
-7
View File
@@ -1,7 +0,0 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chatballs.products"
@@ -1,54 +0,0 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [("identity", "0004_employeeprofile_phone")]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.CreateModel(
name="Product",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
("code", models.SlugField(max_length=64)),
("name", models.CharField(max_length=255)),
(
"status",
models.CharField(
choices=[("ACTIVE", "Активен"), ("DISABLED", "Неактивен")],
default="ACTIVE",
max_length=32,
),
),
("site_url", models.URLField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="products",
to="identity.organization",
),
),
],
options={
"db_table": "identity_product",
"constraints": [
models.UniqueConstraint(
fields=("organization", "code"),
name="uniq_product_org_code",
)
],
},
)
],
)
]
@@ -1,118 +0,0 @@
# Generated by Django 5.2.15 on 2026-06-19 17:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0005_move_product_state'),
('products', '0001_move_product_state'),
]
operations = [
migrations.AddField(
model_name='product',
name='sales_description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='product',
name='summary',
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name='product',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.CreateModel(
name='Offer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=64)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('fulfillment_type', models.CharField(choices=[('SAAS_ACCESS', 'SaaS-доступ'), ('BOX_LICENSE', 'Коробочная лицензия'), ('SUPPORT_EXTENSION', 'Продление поддержки')], max_length=32)),
('payment_type', models.CharField(choices=[('ONE_TIME', 'Разовая оплата'), ('SUBSCRIPTION', 'Подписка')], max_length=32)),
('access_schema', models.JSONField(blank=True, default=dict)),
('fiscal_name', models.CharField(max_length=255)),
('fiscal_attributes', models.JSONField(blank=True, default=dict)),
('subscription_rules', models.JSONField(blank=True, default=dict)),
('is_active', models.BooleanField(default=True)),
('ai_offerable', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('primary_box_offer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='support_extensions', to='products.offer')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='offers', to='products.product')),
],
),
migrations.CreateModel(
name='Price',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField()),
('amount_minor', models.PositiveBigIntegerField()),
('currency', models.CharField(default='RUB', max_length=3)),
('billing_period', models.CharField(choices=[('ONE_TIME', 'Разово'), ('MONTH', 'Месяц'), ('YEAR', 'Год')], max_length=16)),
('valid_from', models.DateTimeField()),
('valid_until', models.DateTimeField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('offer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prices', to='products.offer')),
],
options={
'ordering': ['offer_id', 'billing_period', '-version'],
},
),
migrations.CreateModel(
name='MarketplacePublication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('marketplace_code', models.SlugField(max_length=64)),
('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликовано'), ('DISABLED', 'Отключено')], default='DRAFT', max_length=16)),
('external_reference', models.CharField(blank=True, max_length=255)),
('published_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('price', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='marketplace_publications', to='products.price')),
],
),
migrations.CreateModel(
name='ProductDepartment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='product_links', to='identity.department')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='department_links', to='products.product')),
],
),
migrations.AddConstraint(
model_name='offer',
constraint=models.UniqueConstraint(fields=('product', 'code'), name='uniq_offer_product_code'),
),
migrations.AddConstraint(
model_name='offer',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('fulfillment_type', 'SUPPORT_EXTENSION'), ('primary_box_offer__isnull', False)), models.Q(('fulfillment_type', 'SUPPORT_EXTENSION'), _negated=True), _connector='OR'), name='support_offer_requires_primary_box'),
),
migrations.AddConstraint(
model_name='price',
constraint=models.UniqueConstraint(fields=('offer', 'version'), name='uniq_price_offer_version'),
),
migrations.AddConstraint(
model_name='price',
constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('offer', 'currency', 'billing_period'), name='uniq_active_price_offer_currency_period'),
),
migrations.AddConstraint(
model_name='price',
constraint=models.CheckConstraint(condition=models.Q(('amount_minor__gt', 0)), name='price_amount_positive'),
),
migrations.AddConstraint(
model_name='marketplacepublication',
constraint=models.UniqueConstraint(fields=('marketplace_code', 'price'), name='uniq_marketplace_price_publication'),
),
migrations.AddConstraint(
model_name='productdepartment',
constraint=models.UniqueConstraint(fields=('product', 'department'), name='uniq_product_department'),
),
]
@@ -1,17 +0,0 @@
from django.db import migrations
def assign_products_to_sales(apps, schema_editor):
Product = apps.get_model("products", "Product")
ProductDepartment = apps.get_model("products", "ProductDepartment")
Department = apps.get_model("identity", "Department")
for product in Product.objects.all():
sales = Department.objects.filter(organization_id=product.organization_id, code="sales").first()
if sales is not None:
ProductDepartment.objects.get_or_create(product_id=product.id, department_id=sales.id)
class Migration(migrations.Migration):
dependencies = [("products", "0002_product_sales_description_product_summary_and_more")]
operations = [migrations.RunPython(assign_products_to_sales, migrations.RunPython.noop)]
@@ -1,21 +0,0 @@
# Generated by Django 5.2.15 on 2026-06-19 18:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0003_assign_existing_products_to_sales'),
]
operations = [
migrations.RemoveConstraint(
model_name='price',
name='uniq_price_offer_version',
),
migrations.AddConstraint(
model_name='price',
constraint=models.UniqueConstraint(fields=('offer', 'currency', 'billing_period', 'version'), name='uniq_price_offer_period_version'),
),
]
@@ -1,26 +0,0 @@
# Generated by Django 5.2.15 on 2026-06-26 11:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0004_remove_price_uniq_price_offer_version_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='price',
name='price_amount_positive',
),
migrations.AlterField(
model_name='offer',
name='fiscal_name',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddConstraint(
model_name='price',
constraint=models.CheckConstraint(condition=models.Q(('amount_minor__gte', 0)), name='price_amount_non_negative'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.15 on 2026-06-29 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0005_remove_price_price_amount_positive_and_more'),
]
operations = [
migrations.AddField(
model_name='product',
name='ingest_token_hash',
field=models.CharField(blank=True, db_index=True, max_length=64),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.2.15 on 2026-07-08 21:29
import chatballs.identity.crypto
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('products', '0006_product_ingest_token_hash'),
]
operations = [
migrations.AddField(
model_name='product',
name='support_token_secret',
field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=512),
),
]
@@ -1,16 +0,0 @@
# ADR-HUB-0023: содержательное описание продукта живёт в Знаниях; продукт —
# техническая запись-якорь (токены, офферы, каналы).
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("products", "0007_product_support_token_secret"),
]
operations = [
migrations.RemoveField(model_name="product", name="summary"),
migrations.RemoveField(model_name="product", name="sales_description"),
]
@@ -1,35 +0,0 @@
# Generated by Django 5.2.16 on 2026-07-15 00:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0013_accessprofilecapability_organization_and_more'),
('products', '0008_remove_product_knowledge_fields'),
]
operations = [
migrations.AddField(
model_name='marketplacepublication',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.AddField(
model_name='offer',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.AddField(
model_name='price',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.AddField(
model_name='productdepartment',
name='organization',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
]
@@ -1,85 +0,0 @@
# Generated by Django 5.2.16 on 2026-07-15 00:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0014_alter_accessprofilecapability_organization_and_more'),
('products', '0009_marketplacepublication_organization_and_more'),
]
operations = [
migrations.RunSQL(
sql="""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM products_productdepartment link
JOIN identity_product product ON product.id = link.product_id
JOIN identity_department department ON department.id = link.department_id
WHERE product.organization_id <> department.organization_id
) THEN
RAISE EXCEPTION 'C04 preflight: cross-tenant product department exists';
END IF;
IF EXISTS (
SELECT 1
FROM products_offer offer
JOIN products_offer primary_offer ON primary_offer.id = offer.primary_box_offer_id
JOIN identity_product product ON product.id = offer.product_id
JOIN identity_product primary_product ON primary_product.id = primary_offer.product_id
WHERE product.organization_id <> primary_product.organization_id
) THEN
RAISE EXCEPTION 'C04 preflight: cross-tenant offer relation exists';
END IF;
END $$;
UPDATE products_offer offer
SET organization_id = product.organization_id
FROM identity_product product
WHERE offer.product_id = product.id;
UPDATE products_price price
SET organization_id = product.organization_id
FROM products_offer offer
JOIN identity_product product ON product.id = offer.product_id
WHERE price.offer_id = offer.id;
UPDATE products_marketplacepublication publication
SET organization_id = product.organization_id
FROM products_price price
JOIN products_offer offer ON offer.id = price.offer_id
JOIN identity_product product ON product.id = offer.product_id
WHERE publication.price_id = price.id;
UPDATE products_productdepartment link
SET organization_id = product.organization_id
FROM identity_product product
WHERE link.product_id = product.id;
""",
reverse_sql=migrations.RunSQL.noop,
),
migrations.AlterField(
model_name='marketplacepublication',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.AlterField(
model_name='offer',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.AlterField(
model_name='price',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.AlterField(
model_name='productdepartment',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
]
@@ -1,20 +0,0 @@
# ADR-HUB-0041: коммерческий слой каталога (Offer/Price/MarketplacePublication)
# и ingest-токен вебхука заказов удаляются вместе с доменом продаж.
# Зависимость от tenancy.0017: сначала снимаются таблицы orders_*/sales_*,
# ссылавшиеся на products_offer/products_price.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("products", "0010_alter_marketplacepublication_organization_and_more"),
("tenancy", "0017_drop_commerce"),
]
operations = [
migrations.RemoveField(model_name="product", name="ingest_token_hash"),
migrations.DeleteModel(name="MarketplacePublication"),
migrations.DeleteModel(name="Price"),
migrations.DeleteModel(name="Offer"),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.15 on 2026-09-03 22:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
# Дроп после tenancy-гардов (RLS/триггеры ссылаются на эти таблицы).
('tenancy', '0017_drop_commerce'),
('products', '0011_remove_commerce'),
]
operations = [
migrations.DeleteModel(
name='ProductDepartment',
),
]
@@ -1 +0,0 @@
-34
View File
@@ -1,34 +0,0 @@
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import models
from chatballs.identity.crypto import EncryptedCharField
class ProductStatus(models.TextChoices):
ACTIVE = "ACTIVE", "Активен"
DISABLED = "DISABLED", "Неактивен"
class Product(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="products")
code = models.SlugField(max_length=64)
name = models.CharField(max_length=255)
status = models.CharField(max_length=32, choices=ProductStatus.choices, default=ProductStatus.ACTIVE)
site_url = models.URLField(blank=True)
# Содержательное описание продукта живёт в Знаниях (ADR-HUB-0023): продукт —
# скрытая техническая запись-якорь для каналов, поддержки и webchat (ADR-HUB-0041).
# Секрет проверки Product Support Token (ADR-HUB-0022, SPEC-HUB-0011 §4.3).
# HS256-секрет для подписи токена поддержки; хранится зашифрованным (Fernet),
# в API не отдаётся. Продукт использует его для подписи in-product support token.
support_token_secret = EncryptedCharField(max_length=512, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "identity_product"
constraints = [models.UniqueConstraint(fields=["organization", "code"], name="uniq_product_org_code")]
def __str__(self) -> str:
return f"{self.organization.slug}/{self.code}"
@@ -1,19 +0,0 @@
from django.db.models import QuerySet
from chatballs.products.models import Product
from chatballs.tenancy.context import TenantContext
def products_for_context(context: TenantContext) -> QuerySet[Product]:
return (
Product.objects.filter(organization_id=context.organization_id)
.prefetch_related(
"channels__ai_agent",
"channels__connections",
)
.order_by("name")
)
def product_for_context(*, context: TenantContext, product_id: int) -> Product:
return products_for_context(context).get(id=product_id)
@@ -1,36 +0,0 @@
from chatballs.products.models import Product
def _channel_payload(channel: object) -> dict[str, object]:
# Единый источник каналов продукта: connections + agent, чтобы не
# дублировать выборку отдельным запросом /api/v1/channels/.
agent = getattr(channel, "ai_agent", None)
return {
"id": channel.id,
"code": channel.code,
"name": channel.name,
"isActive": channel.is_active,
"agentId": agent.id if agent else None,
"connections": [
{
"id": connection.id,
"provider": connection.provider,
"name": connection.name,
"status": connection.status,
}
for connection in sorted(channel.connections.all(), key=lambda item: item.id)
],
}
def product_payload(product: Product) -> dict[str, object]:
return {
"id": product.id,
"code": product.code,
"name": product.name,
"status": product.status,
"siteUrl": product.site_url,
"channels": [_channel_payload(channel) for channel in product.channels.all()],
"createdAt": product.created_at.isoformat(),
"updatedAt": product.updated_at.isoformat(),
}
@@ -1,51 +0,0 @@
from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
from chatballs.products.models import Product, ProductStatus
from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True)
class ProductInput:
code: str
name: str
site_url: str = ""
@transaction.atomic
def create_product(*, context: TenantContext, data: ProductInput) -> Product:
organization = context.organization
product = Product(
organization=organization,
code=data.code.strip().lower(),
name=data.name.strip(),
status=ProductStatus.ACTIVE,
site_url=data.site_url.strip(),
)
product.full_clean()
product.save()
return product
@transaction.atomic
def update_product(*, context: TenantContext, product: Product, data: ProductInput) -> Product:
if product.organization_id != context.organization_id:
raise ValidationError({"product": "Product belongs to another organization"})
product.name = data.name.strip()
product.site_url = data.site_url.strip()
product.full_clean(exclude=["code"])
product.save(update_fields=["name", "site_url", "updated_at"])
return product
def set_product_status(
*, context: TenantContext, product: Product, status: ProductStatus
) -> Product:
if product.organization_id != context.organization_id:
raise ValidationError({"product": "Product belongs to another organization"})
if product.status != status:
product.status = status
product.save(update_fields=["status", "updated_at"])
return product
-75
View File
@@ -1,75 +0,0 @@
import json
from django.test import TestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.products.models import Product, ProductStatus
class ProductApiTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def test_create_product_is_active(self) -> None:
response = self.client.post(
"/api/v1/company/products/create/",
data=json.dumps(
{
"code": "academy",
"name": "Academy",
"siteUrl": "https://academy.example.com",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()["product"]
self.assertEqual(payload["status"], ProductStatus.ACTIVE)
def test_update_does_not_change_product_code(self) -> None:
product = Product.objects.get(code="site")
response = self.client.patch(
f"/api/v1/company/products/{product.id}/update/",
data=json.dumps(
{
"code": "changed-code",
"name": "Сайт Updated",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
product.refresh_from_db()
self.assertEqual(product.code, "site")
self.assertEqual(product.name, "Сайт Updated")
def test_product_detail_is_limited_to_user_organization(self) -> None:
other = Organization.objects.create(name="Other", slug="other")
product = Product.objects.create(organization=other, code="other-product", name="Other Product")
response = self.client.get(f"/api/v1/company/products/{product.id}/")
self.assertEqual(response.status_code, 404)
def test_product_payload_exposes_assigned_channels(self) -> None:
from chatballs.channels.models import Channel
product = Product.objects.get(code="site")
Channel.objects.create(organization=self.organization, code="site", name="Сайт", product=product)
response = self.client.get("/api/v1/company/products/")
payload = next(item for item in response.json()["items"] if item["id"] == product.id)
channel = payload["channels"][0]
self.assertEqual(channel["code"], "site")
self.assertTrue(channel["isActive"])
# Единый источник: payload содержит связи, нужные табу каналов продукта.
self.assertIsNone(channel["agentId"])
self.assertEqual(channel["connections"], [])
-12
View File
@@ -1,12 +0,0 @@
from django.urls import path
from chatballs.products import views
urlpatterns = [
path("products/", views.ProductListView.as_view(), name="product-list"),
path("products/create/", views.ProductCreateView.as_view(), name="product-create"),
path("products/<int:product_id>/", views.ProductDetailView.as_view(), name="product-detail"),
path("products/<int:product_id>/update/", views.ProductUpdateView.as_view(), name="product-update"),
path("products/<int:product_id>/activate/", views.ProductActivateView.as_view(), name="product-activate"),
path("products/<int:product_id>/deactivate/", views.ProductDeactivateView.as_view(), name="product-deactivate"),
]
-135
View File
@@ -1,135 +0,0 @@
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
from chatballs.products.models import Product, ProductStatus
from chatballs.products.selectors import product_for_context, products_for_context
from chatballs.products.serializers import product_payload
from chatballs.products.services import ProductInput, create_product, set_product_status, update_product
def _input(body: dict[str, object], *, current: Product | None = None) -> ProductInput:
return ProductInput(
code=str(body.get("code", current.code if current else "")),
name=str(body.get("name", current.name if current else "")),
site_url=str(body.get("siteUrl", current.site_url if current else "")),
)
def _validation_error(error: Exception) -> Response:
if isinstance(error, ValidationError):
if hasattr(error, "message_dict"):
detail = "; ".join(message for messages in error.message_dict.values() for message in messages)
else:
detail = "; ".join(error.messages)
else:
detail = "Product code already exists"
return Response({"detail": detail}, status=400)
class ProductListView(APIView):
permission_classes = [HasCapability]
required_capability = "products.view"
def get(self, request: Request) -> Response:
products = products_for_context(request.tenant_context)
return Response({"items": [product_payload(product) for product in products]})
class ProductCreateView(APIView):
permission_classes = [HasCapability]
required_capability = "products.manage"
def post(self, request: Request) -> Response:
profile = request.tenant_context.membership
data = _input(request.data)
try:
product = create_product(context=request.tenant_context, data=data)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
record_audit_event(
action="products.product_created",
actor=request.user,
organization=profile.organization,
object_type="Product",
object_id=str(product.id),
request=request,
)
product = product_for_context(context=request.tenant_context, product_id=product.id)
return Response({"product": product_payload(product)}, status=201)
class ProductDetailView(APIView):
permission_classes = [HasCapability]
required_capability = "products.view"
def get(self, request: Request, product_id: int) -> Response:
try:
product = product_for_context(context=request.tenant_context, product_id=product_id)
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
return Response({"product": product_payload(product)})
class ProductUpdateView(APIView):
permission_classes = [HasCapability]
required_capability = "products.manage"
def patch(self, request: Request, product_id: int) -> Response:
profile = request.tenant_context.membership
try:
product = product_for_context(context=request.tenant_context, product_id=product_id)
data = _input(request.data, current=product)
product = update_product(
context=request.tenant_context, product=product, data=data
)
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
record_audit_event(
action="products.product_updated",
actor=request.user,
organization=profile.organization,
object_type="Product",
object_id=str(product.id),
request=request,
)
product = product_for_context(context=request.tenant_context, product_id=product.id)
return Response({"product": product_payload(product)})
class ProductStatusView(APIView):
permission_classes = [HasCapability]
required_capability = "products.manage"
status_value: ProductStatus
def post(self, request: Request, product_id: int) -> Response:
profile = request.tenant_context.membership
try:
product = Product.objects.get(id=product_id, organization=profile.organization)
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
set_product_status(
context=request.tenant_context, product=product, status=self.status_value
)
record_audit_event(
action=f"products.product_{self.status_value.lower()}",
actor=request.user,
organization=profile.organization,
object_type="Product",
object_id=str(product.id),
request=request,
)
product = product_for_context(context=request.tenant_context, product_id=product.id)
return Response({"product": product_payload(product)})
class ProductActivateView(ProductStatusView):
status_value = ProductStatus.ACTIVE
class ProductDeactivateView(ProductStatusView):
status_value = ProductStatus.DISABLED
Whitespace-only changes.
-19
View File
@@ -1,19 +0,0 @@
from django.contrib import admin
from chatballs.support.models import ProductSupportContract, SupportIdentitySnapshot
@admin.register(ProductSupportContract)
class ProductSupportContractAdmin(admin.ModelAdmin):
list_display = ("code", "version", "product", "organization", "status", "updated_at")
list_filter = ("status", "organization")
search_fields = ("code", "product__code", "product__name")
filter_horizontal = ("allowed_channels",)
@admin.register(SupportIdentitySnapshot)
class SupportIdentitySnapshotAdmin(admin.ModelAdmin):
list_display = ("subject_key", "product", "contract_code", "display_name", "verified_at")
list_filter = ("product",)
search_fields = ("subject_key", "display_name", "display_email", "search_text")
readonly_fields = ("verified_at", "created_at", "payload_json", "token_jti_hash")
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class SupportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "support"
name = "chatballs.support"
verbose_name = "Support department and product identity contracts"
-40
View File
@@ -1,40 +0,0 @@
"""Машинные коды ошибок support-сессии (SPEC-HUB-0011 §14).
Публичный ответ widget всегда безопасный: {"error":"support_unavailable","message":...}.
Внутренний machine code пишется в audit payload и не раскрывается пользователю.
"""
from __future__ import annotations
# Публичное сообщение для всех категорий отказа — без технических деталей.
PUBLIC_SUPPORT_UNAVAILABLE = (
"Поддержка временно недоступна. Обновите страницу или обратитесь позже."
)
# Внутренние machine codes для audit/debug.
TOKEN_MISSING = "TOKEN_MISSING"
TOKEN_SIGNATURE_INVALID = "TOKEN_SIGNATURE_INVALID"
TOKEN_EXPIRED = "TOKEN_EXPIRED"
TOKEN_AUDIENCE_INVALID = "TOKEN_AUDIENCE_INVALID"
TOKEN_ISSUER_INVALID = "TOKEN_ISSUER_INVALID"
CONTRACT_NOT_FOUND = "CONTRACT_NOT_FOUND"
CONTRACT_DISABLED = "CONTRACT_DISABLED"
CONTRACT_CHANNEL_NOT_ALLOWED = "CONTRACT_CHANNEL_NOT_ALLOWED"
PAYLOAD_SCHEMA_INVALID = "PAYLOAD_SCHEMA_INVALID"
SUBJECT_MAPPING_EMPTY = "SUBJECT_MAPPING_EMPTY"
CHANNEL_NOT_SUPPORT = "CHANNEL_NOT_SUPPORT"
CHANNEL_PRODUCT_MISMATCH = "CHANNEL_PRODUCT_MISMATCH"
ANONYMOUS_NOT_ALLOWED = "ANONYMOUS_NOT_ALLOWED"
class SupportSessionError(Exception):
"""Отказ старта support-сессии с машинным кодом причины.
code — внутренний machine code (для audit); пользователю отдаётся только
безопасное публичное сообщение без раскрытия деталей проверки.
"""
def __init__(self, code: str, message: str = PUBLIC_SUPPORT_UNAVAILABLE) -> None:
super().__init__(message)
self.code = code
-94
View File
@@ -1,94 +0,0 @@
"""Извлечение identity/operator/ai/search контекста по контракту (SPEC-HUB-0011 §6-11).
Валидация payload по schema_json — ручная (нет jsonschema-зависимости):
проверка required-полей object и вложенных object-properties, pattern из orders/views.py.
Глубокая type-проверка — TODO.
"""
from __future__ import annotations
from typing import Any
from chatballs.support import errors, jsonpath
from chatballs.support.models import ProductSupportContract
def validate_schema(data: dict[str, Any], schema: dict[str, Any]) -> None:
"""Ручная проверка required-полей по schema. Поднимает PAYLOAD_SCHEMA_INVALID."""
if not schema:
return
required = schema.get("required") or []
if isinstance(required, list):
for field in required:
if field not in data:
raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID)
# Рекурсивная проверка required во вложенных object-properties.
properties = schema.get("properties") or {}
if isinstance(properties, dict):
for field, subschema in properties.items():
if not isinstance(subschema, dict):
continue
sub_required = subschema.get("required")
value = data.get(field)
if isinstance(sub_required, list) and isinstance(value, dict):
for sub_field in sub_required:
if sub_field not in value:
raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID)
def extract_context(data: dict[str, Any], contract: ProductSupportContract) -> dict[str, Any]:
"""Извлекает identity/operator_cards/ai_context/search по mapping'ам контракта."""
identity = contract.identity_mapping_json or {}
subject = jsonpath.resolve_path(data, identity.get("subject", ""))
if not subject:
raise errors.SupportSessionError(errors.SUBJECT_MAPPING_EMPTY)
account = jsonpath.resolve_path(data, identity.get("account", ""))
display_name = jsonpath.resolve_path(data, identity.get("display_name", "")) or ""
display_email = jsonpath.resolve_path(data, identity.get("display_email", "")) or ""
operator_cards = build_operator_cards(data, contract.operator_ui_json or {})
ai_context = build_ai_context(data, contract.ai_context_json or {})
search_text = build_search_text(data, contract.search_mapping_json or {})
return {
"subject_key": str(subject),
"account_key": str(account) if account else "",
"display_name": str(display_name),
"display_email": str(display_email),
"operator_context": {"operator_cards": operator_cards},
"ai_context": ai_context,
"search_text": search_text,
}
def build_operator_cards(data: dict[str, Any], ui: dict[str, Any]) -> list[dict[str, Any]]:
cards: list[dict[str, Any]] = []
for card in ui.get("operator_cards", []) or []:
fields = []
for field in card.get("fields", []) or []:
value = jsonpath.resolve_path(data, field.get("path", ""))
fields.append(
{
"label": field.get("label", ""),
"value": value,
"type": field.get("type", "text"),
"visibility": field.get("visibility", "operator"),
}
)
cards.append({"title": card.get("title", ""), "fields": fields})
return cards
def build_ai_context(data: dict[str, Any], ai: dict[str, Any]) -> dict[str, Any]:
allowed = ai.get("allowed_paths", []) or []
values: dict[str, Any] = {}
for path in allowed:
values[path] = jsonpath.resolve_path(data, path)
return {"allowed_paths": values}
def build_search_text(data: dict[str, Any], search: dict[str, Any]) -> str:
paths = search.get("paths", []) or []
parts = [str(jsonpath.resolve_path(data, p)) for p in paths]
return " ".join(p for p in parts if p and p != "None")
@@ -1,76 +0,0 @@
"""Ограниченный JSONPath-subset для mapping'ов контракта (SPEC-HUB-0011 §6).
Допускаются только:
$.field
$.field.nested
$.array[0].field — только целочисленный индекс, без filters/scripts
Произвольные expressions, filters или script-like expressions не допускаются.
Отсутствующий обязательный path обрабатывается вызывающей стороной (subject).
"""
from __future__ import annotations
from typing import Any
_MISSING = object()
# Унификация: принимаем как "$.a.b", так и "a.b" (без префикса).
_PREFIX = "$."
def _normalize(path: str) -> str:
if path.startswith(_PREFIX):
return path[len(_PREFIX):]
return path
def resolve_path(data: Any, path: str) -> Any:
"""Возвращает значение по path или None, если path отсутствует.
None для optional paths; вызывающая сторона решает, обязателен ли path.
Некорректный path (не массив по индексу и т.п.) тоже даёт None — контракт
не должен ронять renderer на отсутствующих optional полях (SPEC §6).
"""
if not path or not isinstance(data, dict | list):
return None
current: Any = data
for segment in _normalize(path).split("."):
if segment == "":
continue
# Поддержка array[0] внутри сегмента: field[2]
idx = _array_index(segment)
if idx is not None:
name = segment.split("[", 1)[0]
current = _step(current, name)
if current is _MISSING:
return None
current = _index(current, idx)
if current is _MISSING:
return None
else:
current = _step(current, segment)
if current is _MISSING:
return None
return None if current is _MISSING else current
def _step(current: Any, name: str) -> Any:
if isinstance(current, dict):
return current.get(name, _MISSING)
return _MISSING
def _index(current: Any, idx: int) -> Any:
if isinstance(current, list) and -len(current) <= idx < len(current):
return current[idx]
return _MISSING
def _array_index(segment: str) -> int | None:
if "[" not in segment or not segment.endswith("]"):
return None
inner = segment[segment.index("[") + 1 : -1]
if not inner.lstrip("-").isdigit():
return None
return int(inner)
-278
View File
@@ -1,278 +0,0 @@
"""Poll/send сообщений support-диалога для виджета (SPEC-HUB-0010 §7).
В отличие от sales webchat (ingest_inbound), support-путь НЕ создаёт Contact и НЕ
ходит в мессенджер (transports.send_reply): ответ оператора/AI виджет забирает
polling'ом. AI-путь (run_channel_turn + handoff + system) переиспользуется.
"""
from __future__ import annotations
import logging
from django.db import transaction
from django.utils import timezone
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
from chatballs.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
Message,
MessageAuthor,
MessageKind,
)
from chatballs.notifications.models import NotificationAudience, NotificationType
from chatballs.notifications.services import notify, notify_management
from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
_HISTORY_LIMIT = 20
_ROLE = {
MessageAuthor.CONTACT: "user",
MessageAuthor.AI: "assistant",
MessageAuthor.OPERATOR: "assistant",
MessageAuthor.SYSTEM: "system",
}
_STATE = {
ControlMode.AI: "ai",
ControlMode.HUMAN: "operator",
ControlMode.PAUSED: "waiting",
}
_AUTHOR = {
MessageAuthor.CONTACT: "client",
MessageAuthor.AI: "ai",
MessageAuthor.OPERATOR: "operator",
MessageAuthor.SYSTEM: "system",
}
def _history(conversation: Conversation) -> list[dict]:
messages = list(conversation.messages.order_by("created_at"))
prior = messages[:-1][-_HISTORY_LIMIT:]
# Голосовые попадают в контекст стенограммой.
return [{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript} for m in prior if m.text or m.transcript]
def support_messages_since(conversation: Conversation, since: int) -> dict:
items = conversation.messages.filter(id__gt=since).order_by("created_at")
return {
"state": _STATE.get(conversation.control_mode, "ai"),
"lifecycle": conversation.lifecycle,
"messages": [
{
"id": m.id,
"author": _AUTHOR.get(m.author_type, "ai"),
"kind": m.kind,
"text": m.text,
"createdAt": m.created_at.isoformat(),
"durationSeconds": m.duration_seconds,
"hasAudio": bool(m.audio),
**(
{
"attachment": {
"name": m.attachment_name,
"contentType": m.attachment_content_type,
"size": m.attachment_size,
"available": bool(m.attachment),
}
}
if m.kind == MessageKind.FILE
else {}
),
}
for m in items
],
}
@transaction.atomic
def post_support_message(
*, context: TenantContext, conversation: Conversation, text: str
) -> None:
"""Сохраняет сообщение клиента и запускает AI-ответ (если диалог ведёт AI).
ADR-HUB-0003: AI-first; handoff AI→operator. Без Contact/ConnectionIdentity и
без transports.send_reply (ответ идёт через polling, не через messenger API).
"""
if conversation.organization_id != context.organization_id:
raise ValueError("Support conversation is outside tenant context")
Message.objects.create(conversation=conversation, author_type=MessageAuthor.CONTACT, text=text)
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=["last_activity_at"])
_run_support_ai(context=context, conversation=conversation, text=text)
def _client_label(conversation: Conversation) -> str:
snapshot = conversation.support_identity_snapshot
return (snapshot.display_name if snapshot else "") or "Клиент"
def _hand_to_operator(*, context: TenantContext, conversation: Conversation, reason: str) -> None:
"""Диалог уходит оператору без имитации сбоя AI (голосовое без стенограммы, файл)."""
if conversation.control_mode != ControlMode.AI:
return
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "expected_responder"])
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title=f"Нужен оператор · {_client_label(conversation)}",
body=reason,
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"media:{conversation.id}",
)
@transaction.atomic
def post_support_voice(*, context: TenantContext, conversation: Conversation, content: bytes, content_type: str, duration: int) -> Message:
"""Голосовое из портала поддержки: сохраняем, AI отвечает текстом по
стенограмме; без расшифровки — диалог оператору."""
from django.core.files.base import ContentFile
from chatballs.conversations.ingest import transcribe_voice_message
if conversation.organization_id != context.organization_id:
raise ValueError("Support conversation is outside tenant context")
message = Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.CONTACT,
kind=MessageKind.VOICE,
audio_content_type=content_type,
duration_seconds=duration,
)
suffix = "ogg" if "ogg" in content_type else content_type.rsplit("/", 1)[-1]
message.audio.save(f"voice.{suffix}", ContentFile(content), save=False)
message.save(update_fields=["audio"])
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=["last_activity_at"])
if conversation.control_mode != ControlMode.AI:
return message
transcript = transcribe_voice_message(conversation.channel, message)
if transcript:
_run_support_ai(context=context, conversation=conversation, text=transcript)
else:
_hand_to_operator(context=context, conversation=conversation, reason="Голосовое без расшифровки")
return message
@transaction.atomic
def post_support_file(*, context: TenantContext, conversation: Conversation, content: bytes, filename: str, content_type: str, caption: str = "") -> Message:
"""Файл из портала поддержки: подпись — обычное сообщение (с ответом AI),
сам файл — отдельная реплика; файл без подписи уводит диалог оператору."""
from django.core.files.base import ContentFile
if conversation.organization_id != context.organization_id:
raise ValueError("Support conversation is outside tenant context")
if caption:
Message.objects.create(conversation=conversation, author_type=MessageAuthor.CONTACT, text=caption)
message = Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.CONTACT,
kind=MessageKind.FILE,
attachment_name=filename,
attachment_content_type=content_type,
attachment_size=len(content),
)
message.attachment.save(filename, ContentFile(content), save=False)
message.save(update_fields=["attachment"])
conversation.last_activity_at = timezone.now()
conversation.save(update_fields=["last_activity_at"])
if caption:
_run_support_ai(context=context, conversation=conversation, text=caption)
else:
_hand_to_operator(context=context, conversation=conversation, reason=f"Файл: {filename}")
return message
def _run_support_ai(*, context: TenantContext, conversation: Conversation, text: str) -> None:
client_label = _client_label(conversation)
# AI отвечает только когда диалог ведёт AI (ADR-HUB-0003).
if conversation.control_mode != ControlMode.AI:
return
try:
result = run_channel_turn(
channel=conversation.channel, message=text, history=_history(conversation)
)
except (ProviderError, LimitExceeded) as error:
logger.warning("AI turn failed for support conversation %s: %s", conversation.id, error)
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.last_activity_at = timezone.now()
conversation.save(
update_fields=["control_mode", "expected_responder", "last_activity_at"]
)
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
text="AI недоступен — диалог передан оператору",
)
fallback = (
"Извините, прямо сейчас не получается ответить. Я передал ваш вопрос"
" специалисту — он скоро подключится."
)
Message.objects.create(
conversation=conversation, author_type=MessageAuthor.AI, text=fallback
)
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title=f"Нужен оператор · {client_label}",
body="AI временно недоступен, диалог ждёт ответа",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"aifail:{conversation.id}",
)
notify_management(
context=context,
type=NotificationType.INTEGRATION_ERROR,
title=f"Ошибка AI · {conversation.channel.name}",
body="AI временно недоступен, диалог передан оператору",
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"aierror:{conversation.id}",
)
return
reply = result.text
handoff = HANDOFF_TOKEN in reply
if handoff:
reply = reply.replace(HANDOFF_TOKEN, "").strip()
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
conversation.last_activity_at = timezone.now()
if handoff:
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
else:
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
if handoff:
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
text="AI передал диалог оператору",
)
notify(
context=context,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title=f"AI передал диалог · {client_label}",
body=text[:120],
target_id=conversation.id,
source_type="Conversation",
source_id=conversation.id,
dedup_key=f"handoff:{conversation.id}",
)
@@ -1,77 +0,0 @@
# Generated by Django 5.2.15 on 2026-07-08 21:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('channels', '0002_channel_system_prompt'),
('identity', '0006_alter_employeeprofile_totp_secret'),
('products', '0006_product_ingest_token_hash'),
]
operations = [
migrations.CreateModel(
name='ProductSupportContract',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=64)),
('version', models.PositiveSmallIntegerField()),
('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('ACTIVE', 'Активен'), ('DEPRECATED', 'Устаревший'), ('DISABLED', 'Отключён')], default='DRAFT', max_length=16)),
('schema_json', models.JSONField(blank=True, default=dict)),
('identity_mapping_json', models.JSONField(blank=True, default=dict)),
('operator_ui_json', models.JSONField(blank=True, default=dict)),
('ai_context_json', models.JSONField(blank=True, default=dict)),
('search_mapping_json', models.JSONField(blank=True, default=dict)),
('sensitive_fields_json', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('allowed_channels', models.ManyToManyField(blank=True, related_name='allowed_support_contracts', to='channels.channel')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_contracts', to='identity.organization')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_contracts', to='products.product')),
],
),
migrations.CreateModel(
name='SupportIdentitySnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('contract_code', models.CharField(max_length=64)),
('subject_key', models.CharField(db_index=True, max_length=128)),
('account_key', models.CharField(blank=True, db_index=True, max_length=128, null=True)),
('display_name', models.CharField(blank=True, max_length=255)),
('display_email', models.CharField(blank=True, max_length=255)),
('payload_json', models.JSONField(blank=True, default=dict)),
('operator_context_json', models.JSONField(blank=True, default=dict)),
('ai_context_json', models.JSONField(blank=True, default=dict)),
('search_text', models.TextField(blank=True)),
('verified_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('token_issued_at', models.DateTimeField()),
('token_expires_at', models.DateTimeField()),
('token_jti_hash', models.CharField(blank=True, db_index=True, max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='snapshots', to='support.productsupportcontract')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_identity_snapshots', to='identity.organization')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='support_identity_snapshots', to='products.product')),
],
),
migrations.AddIndex(
model_name='productsupportcontract',
index=models.Index(fields=['product', 'status'], name='support_pro_product_962332_idx'),
),
migrations.AddConstraint(
model_name='productsupportcontract',
constraint=models.UniqueConstraint(fields=('organization', 'code'), name='uniq_support_contract_org_code'),
),
migrations.AddIndex(
model_name='supportidentitysnapshot',
index=models.Index(fields=['product', 'subject_key'], name='support_sup_product_155363_idx'),
),
migrations.AddIndex(
model_name='supportidentitysnapshot',
index=models.Index(fields=['product', 'account_key'], name='support_sup_product_ae9686_idx'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.15 on 2026-07-08 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('support', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='productsupportcontract',
name='code',
field=models.CharField(max_length=64),
),
]
Whitespace-only changes.
-139
View File
@@ -1,139 +0,0 @@
from __future__ import annotations
import re
from django.core.exceptions import ValidationError
from django.db import models
# Отдел поддержки — authenticated in-product чат (ADR-HUB-0022, SPEC-HUB-0010/0011).
# Hub не владеет бизнес-моделью клиента продукта: каждый продукт публикует
# версионированный ProductSupportIdentityContract, а Hub хранит проверенный
# SupportIdentitySnapshot после проверки Product Support Token. Raw token нигде
# не сохраняется — только короткий хэш jti для audit/replay.
# Формат code контракта: <product_code>.support.v<major> (SPEC-HUB-0011 §3).
_CONTRACT_CODE_RE = re.compile(
r"^(?P<product_code>[a-z0-9-]+)\.support\.v(?P<version>[1-9][0-9]*)$"
)
class ContractStatus(models.TextChoices):
DRAFT = "DRAFT", "Черновик"
ACTIVE = "ACTIVE", "Активен"
DEPRECATED = "DEPRECATED", "Устаревший"
DISABLED = "DISABLED", "Отключён"
class ProductSupportContract(models.Model):
"""Версионированный контракт идентификации клиента поддержки продукта.
Описывает JSON Schema payload токена, mapping identity, карточки оператора,
AI-visible context, search и sensitive fields (SPEC-HUB-0011 §5).
"""
organization = models.ForeignKey(
"identity.Organization",
on_delete=models.PROTECT,
related_name="support_contracts",
)
product = models.ForeignKey(
"products.Product",
on_delete=models.PROTECT,
related_name="support_contracts",
)
# Формат <product_code>.support.v<major> содержит точки, поэтому CharField,
# а не SlugField (SlugField не допускает '.'). Формат проверяется в clean().
code = models.CharField(max_length=64)
version = models.PositiveSmallIntegerField()
status = models.CharField(
max_length=16,
choices=ContractStatus.choices,
default=ContractStatus.DRAFT,
)
allowed_channels = models.ManyToManyField(
"channels.Channel",
related_name="allowed_support_contracts",
blank=True,
)
schema_json = models.JSONField(default=dict, blank=True)
identity_mapping_json = models.JSONField(default=dict, blank=True)
operator_ui_json = models.JSONField(default=dict, blank=True)
ai_context_json = models.JSONField(default=dict, blank=True)
search_mapping_json = models.JSONField(default=dict, blank=True)
sensitive_fields_json = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["organization", "code"],
name="uniq_support_contract_org_code",
),
]
indexes = [models.Index(fields=["product", "status"])]
def __str__(self) -> str:
return f"{self.organization.slug}/{self.code}"
def clean(self) -> None:
super().clean()
if self.product.organization_id != self.organization_id:
raise ValidationError({"product": "Product must belong to the same organization"})
match = _CONTRACT_CODE_RE.match(self.code)
if match is None:
raise ValidationError(
{"code": "Contract code must be '<product_code>.support.v<major>'"}
)
if match.group("product_code") != self.product.code:
raise ValidationError({"code": "Contract code product part must match product code"})
if self.version != int(match.group("version")):
raise ValidationError({"version": "Version must match the major in contract code"})
class SupportIdentitySnapshot(models.Model):
"""Проверенный снимок identity/context клиента поддержки (SPEC-HUB-0011 §13).
Исторический снимок: если продукт позже изменил данные или структуру payload,
старый диалог остаётся читаемым в контексте того обращения. Raw token не
сохраняется — только хэш jti для audit/replay-обнаружения.
"""
organization = models.ForeignKey(
"identity.Organization",
on_delete=models.PROTECT,
related_name="support_identity_snapshots",
)
product = models.ForeignKey(
"products.Product",
on_delete=models.PROTECT,
related_name="support_identity_snapshots",
)
contract = models.ForeignKey(
ProductSupportContract,
on_delete=models.PROTECT,
related_name="snapshots",
)
contract_code = models.CharField(max_length=64)
subject_key = models.CharField(max_length=128, db_index=True)
account_key = models.CharField(max_length=128, blank=True, null=True, db_index=True)
display_name = models.CharField(max_length=255, blank=True)
display_email = models.CharField(max_length=255, blank=True)
payload_json = models.JSONField(default=dict, blank=True)
operator_context_json = models.JSONField(default=dict, blank=True)
ai_context_json = models.JSONField(default=dict, blank=True)
search_text = models.TextField(blank=True)
verified_at = models.DateTimeField(auto_now_add=True, db_index=True)
token_issued_at = models.DateTimeField()
token_expires_at = models.DateTimeField()
token_jti_hash = models.CharField(max_length=64, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["product", "subject_key"]),
models.Index(fields=["product", "account_key"]),
]
def __str__(self) -> str:
return f"snapshot:{self.id}/{self.subject_key}"
@@ -1,26 +0,0 @@
from django.urls import path
from chatballs.support import public_views
urlpatterns = [
path(
"sessions/",
public_views.SupportSessionStartView.as_view(),
name="support-session-start",
),
path(
"sessions/messages/",
public_views.SupportSessionMessagesView.as_view(),
name="support-session-messages",
),
path(
"sessions/messages/<int:message_id>/audio/",
public_views.SupportSessionAudioView.as_view(),
name="support-session-audio",
),
path(
"sessions/messages/<int:message_id>/attachment/",
public_views.SupportSessionAttachmentView.as_view(),
name="support-session-attachment",
),
]
@@ -1,294 +0,0 @@
from django.db import models
from django.http import FileResponse
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.conversations.attachment_views import attachment_response, validate_upload
from chatballs.conversations.models import Conversation, Message, MessageKind
from chatballs.conversations.serializers import conversation_payload
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
from chatballs.integrations.features import features_payload, voice_messages_allowed
from chatballs.identity.models import Organization
from chatballs.support import errors
from chatballs.support.messages import post_support_file, post_support_message, post_support_voice, support_messages_since
from chatballs.support.serializers import support_identity_snapshot_payload
from chatballs.support.session import start_support_session
from chatballs.support.token import verify_support_token
from chatballs.support.widget_credential import verify_widget_credential
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import (
support_channel_routes,
support_conversation_route,
web_widget_route,
)
from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
)
from chatballs.webchat.services import origin_allowed
class _Public(APIView):
authentication_classes: list = []
permission_classes = [AllowAny]
class SupportSessionStartView(_Public):
def post(self, request: Request) -> Response:
widget_key = str(request.data.get("widgetKey", "")).strip()
channel_code = str(request.data.get("channel", "")).strip()
token = str(request.data.get("token", ""))
if not token or (not widget_key and not channel_code):
return _denied()
if widget_key:
route = web_widget_route(widget_key)
if route is None:
return _denied()
channel_id = None
else:
routes = support_channel_routes(channel_code)
if len(routes) == 1:
route = routes[0]
else:
verified = []
for candidate in routes:
try:
verify_support_token(token=token, secret=candidate.support_secret)
except errors.SupportSessionError:
continue
verified.append(candidate)
if len(verified) != 1:
return _denied()
route = verified[0]
channel_id = int(route.resource_id)
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
return _denied()
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
widgets = WebChatWidget.objects.select_related(
"organization",
"integration",
"integration__channel",
"integration__channel__product",
).filter(
organization=organization,
mode=WebChatWidgetMode.AUTHENTICATED_PRODUCT,
status=WebChatWidgetStatus.PUBLISHED,
integration__provider="WEB",
integration__status="OK",
integration__is_active=True,
integration__channel__is_active=True,
)
if widget_key:
widget = widgets.filter(
id=route.resource_id,
public_key=widget_key,
).first()
else:
candidates = list(
widgets.filter(
integration__channel_id=channel_id,
integration__channel__code=channel_code,
).order_by("id")[:2]
)
widget = candidates[0] if len(candidates) == 1 else None
origin = str(
request.data.get("hostOrigin", "")
or request.headers.get("Origin")
or request.headers.get("Referer")
or ""
)
if widget is None or not origin_allowed(widget, origin):
return _denied()
try:
result = start_support_session(
widget=widget,
token=token,
request=request,
)
except errors.SupportSessionError:
return _denied()
return Response(
{
"conversation": conversation_payload(
result["conversation"],
with_messages=True,
),
"snapshot": support_identity_snapshot_payload(result["snapshot"]),
"widgetCredential": result["widget_credential"],
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(widget.integration),
},
status=201,
)
def _widget_context(request: Request):
auth = request.headers.get("Authorization", "")
# <audio src>/<img src> не умеют заголовки — credential может прийти параметром.
credential = auth[7:] if auth.startswith("Bearer ") else (request.GET.get("credential", "") if request.method == "GET" else "")
if not credential:
return None
claims = verify_widget_credential(credential)
if claims is None:
return None
route = support_conversation_route(
claims["conversation_id"],
claims["snapshot_id"],
)
if route is None:
return None
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist:
return None
return TenantContext.for_resource(organization), claims
def _resolve_widget_conversation(
context: TenantContext,
claims,
) -> Conversation | None:
return (
Conversation.objects.select_related(
"organization", "channel", "support_identity_snapshot"
)
.filter(
id=claims["conversation_id"],
support_identity_snapshot_id=claims["snapshot_id"],
organization=context.organization,
organization_id=models.F("support_identity_snapshot__organization_id"),
channel__organization_id=models.F("organization_id"),
)
.first()
)
class SupportSessionMessagesView(_Public):
# JSON — текст, multipart — голосовое или файл.
parser_classes = [JSONParser, MultiPartParser, FormParser]
def get(self, request: Request) -> Response:
resolved = _widget_context(request)
if resolved is None:
return Response({"detail": "Сессия не найдена"}, status=401)
context, claims = resolved
try:
since = int(request.GET.get("since", "0") or 0)
except ValueError:
since = 0
with tenant_atomic(context):
conversation = _resolve_widget_conversation(context, claims)
if conversation is None:
return Response({"detail": "Сессия не найдена"}, status=401)
return Response(support_messages_since(conversation, since))
def post(self, request: Request) -> Response:
resolved = _widget_context(request)
if resolved is None:
return Response({"detail": "Сессия не найдена"}, status=401)
text = str(request.data.get("text", "")).strip()
audio = request.FILES.get("audio")
upload = request.FILES.get("file")
if not text and audio is None and upload is None:
return Response({"detail": "Пустое сообщение"}, status=400)
context, claims = resolved
with tenant_atomic(context):
conversation = _resolve_widget_conversation(context, claims)
if conversation is None:
return Response({"detail": "Сессия не найдена"}, status=401)
if audio is not None:
if not voice_messages_allowed(conversation.connection):
return Response({"detail": "Голосовые отключены"}, status=400)
if audio.size > MAX_VOICE_BYTES:
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
content_type = (audio.content_type or "audio/webm").split(";")[0]
if content_type not in ALLOWED_AUDIO_TYPES:
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
try:
duration = max(0, int(request.data.get("duration", 0)))
except (TypeError, ValueError):
duration = 0
post_support_voice(context=context, conversation=conversation, content=audio.read(), content_type=content_type, duration=duration)
return Response({"ok": True}, status=201)
if upload is not None:
problem = validate_upload(upload)
if problem:
return Response({"detail": problem}, status=400)
name = safe_filename(upload.name or "")
post_support_file(
context=context,
conversation=conversation,
content=upload.read(),
filename=name,
content_type=(upload.content_type or "").split(";")[0] or guess_content_type(name),
caption=text[:4000],
)
return Response({"ok": True}, status=201)
post_support_message(
context=context,
conversation=conversation,
text=text[:4000],
)
return Response({"ok": True}, status=201)
def _session_message(request: Request, message_id: int, kind: str):
resolved = _widget_context(request)
if resolved is None:
return None, Response({"detail": "Сессия не найдена"}, status=401)
context, claims = resolved
with tenant_atomic(context):
conversation = _resolve_widget_conversation(context, claims)
if conversation is None:
return None, Response({"detail": "Сессия не найдена"}, status=401)
message = Message.objects.filter(id=message_id, conversation=conversation, kind=kind).first()
if message is None:
return None, Response({"detail": "Сообщение не найдено"}, status=404)
return (context, message), None
class SupportSessionAudioView(_Public):
def get(self, request: Request, message_id: int) -> Response | FileResponse:
found, error = _session_message(request, message_id, MessageKind.VOICE)
if error is not None:
return error
context, message = found
if not message.audio:
return Response({"detail": "Аудио недоступно"}, status=404)
with tenant_atomic(context):
response = FileResponse(message.audio.open("rb"), content_type=message.audio_content_type or "audio/ogg")
response["Cache-Control"] = "private, max-age=3600"
return response
class SupportSessionAttachmentView(_Public):
def get(self, request: Request, message_id: int) -> Response | FileResponse:
found, error = _session_message(request, message_id, MessageKind.FILE)
if error is not None:
return error
context, message = found
if not message.attachment:
return Response({"detail": "Файл недоступен"}, status=404)
with tenant_atomic(context):
response = attachment_response(message, inline="inline" in request.GET)
response["Cache-Control"] = "private, max-age=3600"
return response
def _denied() -> Response:
return Response(
{
"error": "support_unavailable",
"message": errors.PUBLIC_SUPPORT_UNAVAILABLE,
},
status=422,
)
@@ -1,61 +0,0 @@
from __future__ import annotations
from django.db.models import QuerySet
from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
from chatballs.tenancy.context import TenantContext
def contracts_for_context(context: TenantContext) -> QuerySet[ProductSupportContract]:
return (
ProductSupportContract.objects.filter(organization_id=context.organization_id)
.select_related("product")
.prefetch_related("allowed_channels")
.order_by("code")
)
def contract_for_context(
*, context: TenantContext, contract_id: int
) -> ProductSupportContract:
return contracts_for_context(context).get(id=contract_id)
def contract_by_code(*, context: TenantContext, code: str) -> ProductSupportContract | None:
"""Контракт по code (любой статус) — для различения CONTRACT_NOT_FOUND/DISABLED."""
return (
ProductSupportContract.objects.filter(
organization_id=context.organization_id, code=code
)
.select_related("product")
.first()
)
def active_contract_for(*, context: TenantContext, code: str) -> ProductSupportContract | None:
"""Контракт, принимающий production traffic: ACTIVE или DEPRECATED (migration window)."""
return (
ProductSupportContract.objects.filter(
organization_id=context.organization_id, code=code
)
.filter(status__in=[ContractStatus.ACTIVE, ContractStatus.DEPRECATED])
.select_related("product")
.first()
)
def snapshots_for_subject(
*, context: TenantContext, product_id: int, subject_key: str
) -> QuerySet[SupportIdentitySnapshot]:
return (
SupportIdentitySnapshot.objects.filter(
organization_id=context.organization_id,
product_id=product_id,
subject_key=subject_key,
)
.order_by("-verified_at")
)
@@ -1,42 +0,0 @@
from __future__ import annotations
from chatballs.support.models import ProductSupportContract, SupportIdentitySnapshot
def support_contract_payload(contract: ProductSupportContract) -> dict[str, object]:
return {
"id": contract.id,
"code": contract.code,
"version": contract.version,
"status": contract.status,
"product": {"code": contract.product.code, "name": contract.product.name},
"allowedChannels": [
{"id": ch.id, "code": ch.code, "name": ch.name}
for ch in contract.allowed_channels.all()
],
"schemaJson": contract.schema_json,
"identityMappingJson": contract.identity_mapping_json,
"operatorUiJson": contract.operator_ui_json,
"aiContextJson": contract.ai_context_json,
"searchMappingJson": contract.search_mapping_json,
"sensitiveFieldsJson": contract.sensitive_fields_json,
"createdAt": contract.created_at.isoformat(),
"updatedAt": contract.updated_at.isoformat(),
}
def support_identity_snapshot_payload(snapshot: SupportIdentitySnapshot) -> dict[str, object]:
# payload_json НЕ отдаётся — внутреннее; оператор получает operator_context_json.
return {
"id": snapshot.id,
"product": {"code": snapshot.product.code, "name": snapshot.product.name},
"contractCode": snapshot.contract_code,
"subjectKey": snapshot.subject_key,
"accountKey": snapshot.account_key,
"displayName": snapshot.display_name,
"displayEmail": snapshot.display_email,
"operatorContextJson": snapshot.operator_context_json,
"aiContextJson": snapshot.ai_context_json,
"searchText": snapshot.search_text,
"verifiedAt": snapshot.verified_at.isoformat(),
}
@@ -1,98 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
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)
class ContractInput:
code: str
product_id: int
status: str = ContractStatus.DRAFT
schema_json: dict = None # type: ignore[assignment]
identity_mapping_json: dict = None # type: ignore[assignment]
operator_ui_json: dict = None # type: ignore[assignment]
ai_context_json: dict = None # type: ignore[assignment]
search_mapping_json: dict = None # type: ignore[assignment]
sensitive_fields_json: dict = None # type: ignore[assignment]
allowed_channel_ids: tuple[int, ...] = ()
def __post_init__(self) -> None:
# None → dict, чтобы JSONField(default=dict) был согласован.
for f in (
"schema_json",
"identity_mapping_json",
"operator_ui_json",
"ai_context_json",
"search_mapping_json",
"sensitive_fields_json",
):
if getattr(self, f) is None:
object.__setattr__(self, f, {})
def _resolve_channels(organization: Organization, ids: tuple[int, ...]) -> list[Channel]:
if not ids:
return []
channels = list(Channel.objects.filter(organization=organization, id__in=ids))
if len(channels) != len(set(ids)):
raise ValidationError({"allowedChannelIds": "Channel not found"})
return channels
@transaction.atomic
def register_contract(
*, context: TenantContext, data: ContractInput
) -> ProductSupportContract:
organization = context.organization
try:
product = Product.objects.get(id=data.product_id, organization=organization)
except Product.DoesNotExist as error:
raise ValidationError({"productId": "Product not found"}) from error
contract = ProductSupportContract(
organization=organization,
product=product,
code=data.code.strip().lower(),
version=_version_from_code(data.code),
status=data.status,
schema_json=data.schema_json,
identity_mapping_json=data.identity_mapping_json,
operator_ui_json=data.operator_ui_json,
ai_context_json=data.ai_context_json,
search_mapping_json=data.search_mapping_json,
sensitive_fields_json=data.sensitive_fields_json,
)
contract.full_clean()
contract.save()
for channel in _resolve_channels(organization, data.allowed_channel_ids):
contract.allowed_channels.add(channel)
return contract
def set_contract_status(
*, context: TenantContext, contract: ProductSupportContract, status: str
) -> ProductSupportContract:
if contract.organization_id != context.organization_id:
raise ValidationError({"contract": "Contract belongs to another organization"})
if status not in ContractStatus.values:
raise ValidationError({"status": "Unknown contract status"})
if contract.status != status:
contract.status = status
contract.save(update_fields=["status", "updated_at"])
return contract
def _version_from_code(code: str) -> int:
# <product_code>.support.v<major>; clean() дополнительно валидирует формат.
tail = code.strip().lower().rsplit(".v", 1)[-1]
if not tail.isdigit():
raise ValidationError({"code": "Contract code must end with .v<major>"})
return int(tail)
-257
View File
@@ -1,257 +0,0 @@
"""Проверка Product Support Token и старт support-сессии (SPEC-HUB-0011 §12).
Алгоритм start: channel checks → token verify → contract lookup → schema
validation → identity/operator/ai/search extraction → snapshot upsert →
create-or-continue Conversation → audit (success/denied). Raw token нигде
не сохраняется и не логируется — только короткий хэш jti.
"""
from __future__ import annotations
from typing import Any
from django.db import transaction
from django.http import HttpRequest
from chatballs.conversations.models import (
ControlMode,
Conversation,
ExpectedResponder,
LifecycleState,
)
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 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:
"""Fail-closed проверки канала (SPEC §5.2)."""
if channel.product_id is None:
return errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH)
if channel.requires_authenticated_product_identity and not channel.allow_anonymous_sessions:
return None # корректный support-канал
return errors.SupportSessionError(errors.CHANNEL_NOT_SUPPORT)
def verify_and_resolve(
*, channel, token: str
) -> tuple[TokenClaims, ProductSupportContract, dict[str, Any]]:
"""Шаги 1–13 алгоритма: channel → token → contract → schema → mapping.
Возвращает (claims, contract, extracted) без записи в БД. Raw token не трогаем.
"""
channel_error = _deny_channel_policy(channel)
if channel_error is not None:
raise channel_error
product = channel.product
secret = product.support_token_secret or ""
claims = verify_support_token(token=token, secret=secret)
# iss должен совпадать с кодом продукта канала.
if claims.iss != product.code:
raise errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH)
context = TenantContext.for_resource(channel.organization)
contract = contract_by_code(context=context, code=claims.contract)
if contract is None:
raise errors.SupportSessionError(errors.CONTRACT_NOT_FOUND)
# DRAFT и DISABLED не принимают production traffic (SPEC-HUB-0011 §3).
if contract.status in {ContractStatus.DRAFT, ContractStatus.DISABLED}:
raise errors.SupportSessionError(errors.CONTRACT_DISABLED)
if not contract.allowed_channels.filter(id=channel.id).exists():
raise errors.SupportSessionError(errors.CONTRACT_CHANNEL_NOT_ALLOWED)
validate_schema(claims.data, contract.schema_json)
extracted = extract_context(claims.data, contract)
return claims, contract, extracted
def start_support_session(
*, widget, token: str, request: HttpRequest | None = None
) -> dict[str, Any]:
"""Создаёт/обновляет snapshot и создаёт/продолжает support Conversation.
При любой ошибке проверки пишет audit (DENIED) с машинным кодом и хэшем jti,
затем поднимает SupportSessionError. Raw token не попадает в audit.
Audit-deny пишется вне write-транзакции: иначе откат при raise уничтожил бы
запись. Успешный путь (snapshot + conversation + audit-success) атомарен.
"""
channel = widget.integration.channel
organization: Organization = widget.organization
try:
claims, contract, extracted = verify_and_resolve(channel=channel, token=token)
except errors.SupportSessionError as error:
_audit_denied(
organization=organization, channel=channel, request=request,
error=error, token=token,
)
raise
return _commit_session(
organization=organization, channel=channel, contract=contract,
claims=claims, extracted=extracted, widget=widget, request=request,
)
@transaction.atomic
def _commit_session(
*, organization, channel, contract, claims, extracted, widget, request
) -> dict[str, Any]:
issued_at, expires_at = claims_datetimes(claims)
snapshot = _upsert_snapshot(
organization=organization,
channel=channel,
contract=contract,
claims=claims,
extracted=extracted,
issued_at=issued_at,
expires_at=expires_at,
)
conversation = _create_or_continue_conversation(
organization=organization,
channel=channel,
snapshot=snapshot,
widget=widget,
)
record_audit_event(
action="support.session_started",
result=AuditResult.SUCCESS,
organization=organization,
object_type="Conversation",
object_id=str(conversation.id),
payload={
"contract": contract.code,
"subject_key": extracted["subject_key"],
"product": channel.product.code,
"web_chat_widget_id": widget.id,
"jti_hash": claims.jti_hash[:16],
},
request=request,
)
widget_credential = issue_widget_credential(
conversation_id=conversation.id, snapshot_id=snapshot.id
)
return {
"conversation": conversation,
"snapshot": snapshot,
"widget_credential": widget_credential,
}
def _audit_denied(
*, organization, channel, request, error: errors.SupportSessionError, token: str
) -> None:
# jti_hash безопасен; iss/contract неизвестны до verify — извлекаем только jti без raw token.
jti_hash = _safe_jti_hash(token)
record_audit_event(
action="support.session_denied",
result=AuditResult.DENIED,
organization=organization,
object_type="Channel",
object_id=str(channel.id),
payload={"code": error.code, "jti_hash": jti_hash},
request=request,
)
def _safe_jti_hash(token: str) -> str:
"""Хэш jti без раскрытия raw token: best-effort извлечение payload для audit."""
try:
parts = token.split(".")
if len(parts) != 3:
return ""
import base64
import hashlib
import json as _json
padding = "=" * (-len(parts[1]) % 4)
payload = _json.loads(base64.urlsafe_b64decode(parts[1] + padding))
jti = payload.get("jti", "")
return hashlib.sha256(str(jti).encode("utf-8")).hexdigest()[:16] if jti else ""
except Exception:
return ""
def _upsert_snapshot(
*, organization, channel, contract, claims, extracted, issued_at, expires_at
) -> SupportIdentitySnapshot:
snapshot, _created = SupportIdentitySnapshot.objects.update_or_create(
product=channel.product,
subject_key=extracted["subject_key"],
defaults={
"organization": organization,
"contract": contract,
"contract_code": contract.code,
"account_key": extracted["account_key"] or None,
"display_name": extracted["display_name"],
"display_email": extracted["display_email"],
"payload_json": claims.data,
"operator_context_json": extracted["operator_context"],
"ai_context_json": extracted["ai_context"],
"search_text": extracted["search_text"],
"token_issued_at": issued_at,
"token_expires_at": expires_at,
"token_jti_hash": claims.jti_hash,
},
)
return snapshot
def _create_or_continue_conversation(*, organization, channel, snapshot, widget) -> Conversation:
conversation = (
Conversation.objects.filter(
channel=channel, support_identity_snapshot=snapshot, lifecycle=LifecycleState.OPEN
)
.order_by("-last_activity_at")
.first()
)
if conversation is not None:
metadata = conversation.transport_meta or {}
conversation.transport_meta = {
**metadata,
"webChatWidgetId": metadata.get("webChatWidgetId", widget.id),
"lastWebChatWidgetId": widget.id,
}
update_fields = ["transport_meta"]
if conversation.connection_id is None:
conversation.connection = widget.integration
update_fields.append("connection")
conversation.save(update_fields=update_fields)
return conversation
previous = (
Conversation.objects.filter(
channel=channel, support_identity_snapshot=snapshot
)
.order_by("-created_at")
.first()
)
conversation = Conversation.objects.create(
organization=channel.organization,
channel=channel,
# Диалог наследует группу канала при создании (ADR-HUB-0043 §3).
group=channel.group,
connection=widget.integration,
contact=None,
support_identity_snapshot=snapshot,
transport_meta={
"webChatWidgetId": widget.id,
"lastWebChatWidgetId": widget.id,
},
control_mode=ControlMode.AI,
expected_responder=ExpectedResponder.AI,
previous_conversation=previous,
)
return conversation
@@ -1,55 +0,0 @@
"""Хелпер генерации Product Support Token для тестов (HS256, SPEC-HUB-0011 §4).
Вне тестов не используется: продуктовый backend выпускает токен своим секретом.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Any
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def make_support_token(
*,
secret: str,
iss: str = "app",
contract: str = "app.support.v1",
data: dict[str, Any] | None = None,
exp_delta: int = 600,
jti: str = "test_jti_001",
aud: str = "chatballs.support",
) -> str:
"""Подписанный HS256 Product Support Token для тестов."""
header = {"alg": "HS256", "typ": "JWT"}
now = int(time.time())
payload = {
"iss": iss,
"aud": aud,
"contract": contract,
"iat": now,
"exp": now + exp_delta,
"jti": jti,
"data": data or {},
}
header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
signature_b64 = _b64url_encode(signature)
return f"{header_b64}.{payload_b64}.{signature_b64}"
# Пример payload Acme (SPEC-HUB-0011 §4.1 / §5.2).
ACME_DATA = {
"doctor": {"id": "u_456", "name": "Иван Петров", "email": "doctor@example.com"},
"clinic": {"id": "c_123", "name": "Клиника Альфа"},
"subscription": {"tariff": "pro", "status": "active"},
}
@@ -1,211 +0,0 @@
"""Тесты widget-credential и poll/send endpoints support-виджета (SPEC §7)."""
from __future__ import annotations
import json
from django.test import TestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_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 ACME_DATA, make_support_token
from chatballs.webchat.testing import create_web_widget
SECRET = "test-support-secret-very-long-32bytes!!"
def _setup_support_channel(
organization, product
) -> tuple[Channel, ProductSupportContract]:
product.support_token_secret = SECRET
product.save(update_fields=["support_token_secret"])
contract = ProductSupportContract.objects.create(
organization=organization,
product=product,
code="app.support.v1",
version=1,
status=ContractStatus.ACTIVE,
schema_json={
"type": "object",
"required": ["doctor"],
"properties": {
"doctor": {
"type": "object",
"required": ["id", "email"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"email": {"type": "string"},
},
}
},
},
identity_mapping_json={
"subject": "$.doctor.id",
"display_name": "$.doctor.name",
"display_email": "$.doctor.email",
},
operator_ui_json={"operator_cards": []},
ai_context_json={"allowed_paths": ["$.doctor.name"]},
search_mapping_json={"paths": ["$.doctor.email"]},
sensitive_fields_json={"paths": []},
)
channel = Channel.objects.create(
organization=organization,
code="app-support",
name="Acme — поддержка",
product=product,
requires_authenticated_product_identity=True,
allow_anonymous_sessions=False,
allow_self_reported_contact=False,
allow_sales_attribution=False,
allow_checkout_actions=False,
)
contract.allowed_channels.add(channel)
return channel, contract
class SupportWidgetMessagesTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.product = Product.objects.get(organization=self.organization, code="app")
self.channel, self.contract = _setup_support_channel(
self.organization, self.product
)
self.widget = create_web_widget(self.channel, name="Acme support widget")
self.client = APIClient()
def _start_session(self) -> dict:
token = make_support_token(secret=SECRET, data=ACME_DATA)
response = self.client.post(
"/api/v1/support/sessions/",
data=json.dumps({"widgetKey": self.widget.public_key, "token": token}),
content_type="application/json",
)
self.assertEqual(response.status_code, 201, response.content)
return response.json()
def _send(self, credential: str, conversation_id: int, text: str = "Не работает импорт"):
return self.client.post(
"/api/v1/support/sessions/messages/",
data=json.dumps({"conversation": conversation_id, "text": text}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {credential}",
)
def _poll(self, credential: str, since: int = 0):
return self.client.get(
f"/api/v1/support/sessions/messages/?since={since}",
HTTP_AUTHORIZATION=f"Bearer {credential}",
)
def test_session_issues_widget_credential(self) -> None:
body = self._start_session()
self.assertIn("widgetCredential", body)
self.assertTrue(body["widgetCredential"])
self.assertEqual(body["conversation"]["id"], body["conversation"]["id"])
def test_send_and_poll_returns_messages(self) -> None:
body = self._start_session()
credential = body["widgetCredential"]
conversation_id = body["conversation"]["id"]
send_resp = self._send(credential, conversation_id)
self.assertEqual(send_resp.status_code, 201, send_resp.content)
# Poll возвращает сообщение клиента (AI может не ответить без провайдера в тестах).
poll_resp = self._poll(credential)
self.assertEqual(poll_resp.status_code, 200, poll_resp.content)
texts = [m["text"] for m in poll_resp.json()["messages"]]
self.assertIn("Не работает импорт", texts)
def test_invalid_credential_rejected(self) -> None:
self._start_session()
resp = self._poll("invalid.credential")
self.assertEqual(resp.status_code, 401)
def test_send_without_credential_rejected(self) -> None:
body = self._start_session()
resp = self.client.post(
"/api/v1/support/sessions/messages/",
data=json.dumps({"conversation": body["conversation"]["id"], "text": "x"}),
content_type="application/json",
)
self.assertEqual(resp.status_code, 401)
def test_empty_message_rejected(self) -> None:
body = self._start_session()
resp = self._send(body["widgetCredential"], body["conversation"]["id"], " ")
self.assertEqual(resp.status_code, 400)
def test_voice_and_file_round_trip(self) -> None:
from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from chatballs.conversations.models import ControlMode, Conversation, MessageKind
body = self._start_session()
self.assertTrue(body["features"]["voiceMessages"])
credential = body["widgetCredential"]
# Голосовое: стенограмма → AI отвечает текстом.
with (
mock.patch("chatballs.ai.provider.local.LocalProvider.transcribe", return_value="Не работает импорт"),
mock.patch("chatballs.support.messages.run_channel_turn", return_value=mock.Mock(text="Проверьте формат файла.")) as run,
):
posted = self.client.post(
"/api/v1/support/sessions/messages/",
data={"audio": SimpleUploadedFile("voice.webm", b"WEBMDATA", content_type="audio/webm"), "duration": "3"},
format="multipart",
HTTP_AUTHORIZATION=f"Bearer {credential}",
)
self.assertEqual(posted.status_code, 201, posted.content)
self.assertEqual(run.call_args.kwargs["message"], "Не работает импорт")
conversation = Conversation.objects.get(id=body["conversation"]["id"])
voice = conversation.messages.get(kind=MessageKind.VOICE)
self.assertEqual(voice.transcript, "Не работает импорт")
items = self._poll(credential).json()["messages"]
voice_item = next(m for m in items if m["kind"] == MessageKind.VOICE)
self.assertTrue(voice_item["hasAudio"])
self.assertEqual(voice_item["durationSeconds"], 3)
self.assertIn("Проверьте формат файла.", [m["text"] for m in items])
audio = self.client.get(f"/api/v1/support/sessions/messages/{voice.id}/audio/?credential={credential}")
self.assertEqual(audio.status_code, 200)
self.assertEqual(audio.headers["Content-Type"], "audio/webm")
self.assertEqual(self.client.get(f"/api/v1/support/sessions/messages/{voice.id}/audio/?credential=bad").status_code, 401)
# Файл без подписи — диалог оператору; вложение отдаётся по credential.
posted = self.client.post(
"/api/v1/support/sessions/messages/",
data={"file": SimpleUploadedFile("лог.txt", b"error", content_type="text/plain")},
format="multipart",
HTTP_AUTHORIZATION=f"Bearer {credential}",
)
self.assertEqual(posted.status_code, 201, posted.content)
conversation.refresh_from_db()
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
attachment = conversation.messages.get(kind=MessageKind.FILE)
item = next(m for m in self._poll(credential).json()["messages"] if m["kind"] == MessageKind.FILE)
self.assertEqual(item["attachment"]["name"], "лог.txt")
served = self.client.get(f"/api/v1/support/sessions/messages/{attachment.id}/attachment/?credential={credential}")
self.assertEqual(served.status_code, 200)
self.assertEqual(b"".join(served.streaming_content), b"error")
def test_voice_disabled_for_entry_point(self) -> None:
from django.core.files.uploadedfile import SimpleUploadedFile
self.widget.integration.voice_messages_enabled = False
self.widget.integration.save(update_fields=["voice_messages_enabled"])
body = self._start_session()
self.assertFalse(body["features"]["voiceMessages"])
posted = self.client.post(
"/api/v1/support/sessions/messages/",
data={"audio": SimpleUploadedFile("voice.webm", b"WEBMDATA", content_type="audio/webm")},
format="multipart",
HTTP_AUTHORIZATION=f"Bearer {body['widgetCredential']}",
)
self.assertEqual(posted.status_code, 400)
-278
View File
@@ -1,278 +0,0 @@
from __future__ import annotations
import json
from django.test import TestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.channels.models import Channel
from chatballs.identity.audit import AuditResult
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import AuditEvent, Organization
from chatballs.products.models import Product
from chatballs.support.models import (
ContractStatus,
ProductSupportContract,
SupportIdentitySnapshot,
)
from chatballs.support.test_helpers import ACME_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!!"
def _app_contract(organization, product) -> ProductSupportContract:
contract = ProductSupportContract.objects.create(
organization=organization,
product=product,
code="app.support.v1",
version=1,
status=ContractStatus.ACTIVE,
schema_json={
"type": "object",
"required": ["doctor"],
"properties": {
"doctor": {
"type": "object",
"required": ["id", "email"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"email": {"type": "string", "format": "email"},
},
},
"clinic": {
"type": "object",
"properties": {"id": {"type": "string"}, "name": {"type": "string"}},
},
"subscription": {
"type": "object",
"properties": {
"tariff": {"type": "string"},
"status": {"type": "string"},
},
},
},
},
identity_mapping_json={
"subject": "$.doctor.id",
"account": "$.clinic.id",
"display_name": "$.doctor.name",
"display_email": "$.doctor.email",
},
operator_ui_json={
"operator_cards": [
{
"title": "Пользователь",
"fields": [{"label": "Имя", "path": "$.doctor.name", "type": "text"}],
},
]
},
ai_context_json={"allowed_paths": ["$.doctor.name", "$.subscription.status"]},
search_mapping_json={"paths": ["$.doctor.email", "$.doctor.name", "$.clinic.name"]},
sensitive_fields_json={"paths": ["$.doctor.email"]},
)
return contract
class SupportSessionTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.product = Product.objects.get(organization=self.organization, code="app")
self.product.support_token_secret = SECRET
self.product.save(update_fields=["support_token_secret"])
self.contract = _app_contract(self.organization, self.product)
self.channel = Channel.objects.create(
organization=self.organization,
code="app-support",
name="Acme — поддержка",
product=self.product,
requires_authenticated_product_identity=True,
allow_anonymous_sessions=False,
allow_self_reported_contact=False,
allow_sales_attribution=False,
allow_checkout_actions=False,
)
self.contract.allowed_channels.add(self.channel)
self.widget = create_web_widget(self.channel, name="Acme support widget")
self.client = APIClient()
def _start(self, token: str, widget_key: str | None = None):
return self.client.post(
"/api/v1/support/sessions/",
data=json.dumps({"widgetKey": widget_key or self.widget.public_key, "token": token}),
content_type="application/json",
)
def test_happy_path_creates_snapshot_and_conversation(self) -> None:
token = make_support_token(secret=SECRET, data=ACME_DATA)
response = self._start(token)
self.assertEqual(response.status_code, 201, response.content)
body = response.json()
self.assertIn("conversation", body)
self.assertIn("snapshot", body)
snapshot = SupportIdentitySnapshot.objects.get(subject_key="u_456")
self.assertEqual(snapshot.contract_code, "app.support.v1")
self.assertEqual(snapshot.display_name, "Иван Петров")
self.assertEqual(snapshot.display_email, "doctor@example.com")
self.assertEqual(snapshot.account_key, "c_123")
self.assertIn("Иван Петров", snapshot.search_text)
# AI context содержит только разрешённые пути.
self.assertIn("$.doctor.name", snapshot.ai_context_json["allowed_paths"])
self.assertNotIn("$.doctor.email", snapshot.ai_context_json["allowed_paths"])
# Raw token не сохранён в snapshot.
self.assertNotIn("token", json.dumps(snapshot.payload_json))
self.assertTrue(snapshot.token_jti_hash)
# Audit SUCCESS.
self.assertTrue(
AuditEvent.objects.filter(
action="support.session_started", result=AuditResult.SUCCESS
).exists()
)
def test_invalid_signature_denied(self) -> None:
token = make_support_token(secret="wrong-secret", data=ACME_DATA)
response = self._start(token)
self.assertEqual(response.status_code, 422)
self.assertEqual(response.json()["error"], "support_unavailable")
self._assert_denied_audit("TOKEN_SIGNATURE_INVALID")
def test_expired_token_denied(self) -> None:
token = make_support_token(secret=SECRET, data=ACME_DATA, exp_delta=-100)
response = self._start(token)
self.assertEqual(response.status_code, 422)
self._assert_denied_audit("TOKEN_EXPIRED")
def test_schema_error_denied(self) -> None:
# Нет обязательного поля doctor.
token = make_support_token(secret=SECRET, data={"clinic": {"id": "c_1"}})
response = self._start(token)
self.assertEqual(response.status_code, 422)
self._assert_denied_audit("PAYLOAD_SCHEMA_INVALID")
def test_missing_subject_denied(self) -> None:
# doctor.id пустой — schema проходит (ключ есть), но subject mapping пуст.
token = make_support_token(
secret=SECRET, data={"doctor": {"id": "", "email": "x@example.com"}}
)
response = self._start(token)
self.assertEqual(response.status_code, 422)
self._assert_denied_audit("SUBJECT_MAPPING_EMPTY")
def test_disabled_contract_denied(self) -> None:
self.contract.status = ContractStatus.DISABLED
self.contract.save(update_fields=["status"])
token = make_support_token(secret=SECRET, data=ACME_DATA)
response = self._start(token)
self.assertEqual(response.status_code, 422)
self._assert_denied_audit("CONTRACT_DISABLED")
def test_wrong_channel_not_support(self) -> None:
# Канал без support-политики (анонимные сессии разрешены) не может
# принимать support-токен.
sales_channel = Channel.objects.create(
organization=self.organization, code="app-sales-x",
name="Acme sales", product=self.product,
)
invalid_widget = create_web_widget(
sales_channel,
name="Invalid support widget",
mode=WebChatWidgetMode.AUTHENTICATED_PRODUCT,
)
token = make_support_token(secret=SECRET, data=ACME_DATA)
response = self._start(token, widget_key=invalid_widget.public_key)
self.assertEqual(response.status_code, 422)
self._assert_denied_audit("CHANNEL_NOT_SUPPORT")
def test_channel_product_mismatch_denied(self) -> None:
# iss=site, но канал привязан к app.
token = make_support_token(secret=SECRET, iss="site", data=ACME_DATA)
response = self._start(token)
self.assertEqual(response.status_code, 422)
self._assert_denied_audit("CHANNEL_PRODUCT_MISMATCH")
def test_missing_token_denied(self) -> None:
response = self.client.post(
"/api/v1/support/sessions/",
data=json.dumps({"widgetKey": self.widget.public_key}),
content_type="application/json",
)
self.assertEqual(response.status_code, 422)
self.assertEqual(response.json()["error"], "support_unavailable")
def test_continue_session_reuses_open_conversation(self) -> None:
token1 = make_support_token(secret=SECRET, data=ACME_DATA, jti="jti-1")
first = self._start(token1)
self.assertEqual(first.status_code, 201)
conv_id = first.json()["conversation"]["id"]
# Повторный старт тем же subject → тот же открытый диалог.
token2 = make_support_token(secret=SECRET, data=ACME_DATA, jti="jti-2")
second = self._start(token2)
self.assertEqual(second.status_code, 201)
self.assertEqual(second.json()["conversation"]["id"], conv_id)
def test_raw_token_not_in_audit(self) -> None:
token = make_support_token(secret="wrong-secret", data=ACME_DATA, jti="secret-jti-xyz")
self._start(token)
denied = AuditEvent.objects.filter(action="support.session_denied").first()
self.assertIsNotNone(denied)
payload_str = json.dumps(denied.payload)
# Raw токен и raw jti не должны попасть в audit — только хэш jti.
self.assertNotIn(token, payload_str)
self.assertNotIn("secret-jti-xyz", payload_str)
def _assert_denied_audit(self, code: str) -> None:
self.assertTrue(
AuditEvent.objects.filter(
action="support.session_denied",
result=AuditResult.DENIED,
payload__code=code,
).exists(),
f"expected denied audit with code={code}",
)
class SupportContractApiTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.product = Product.objects.get(organization=self.organization, code="app")
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def test_owner_registers_contract(self) -> None:
response = self.client.post(
"/api/v1/support/contracts/",
data=json.dumps({
"code": "app.support.v2",
"productId": self.product.id,
"status": "DRAFT",
"schemaJson": {},
"identityMappingJson": {"subject": "$.user.id"},
}),
content_type="application/json",
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(response.json()["contract"]["code"], "app.support.v2")
self.assertEqual(response.json()["contract"]["version"], 2)
def test_operator_cannot_register_contract(self) -> None:
# Оператор (роль EMPLOYEE) не имеет прав на управление контрактами.
self.client.login(username="staff.member@example.org", password="Operator-Local-2026")
response = self.client.post(
"/api/v1/support/contracts/",
data=json.dumps({"code": "app.support.v3", "productId": self.product.id}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_contract_code_must_match_product(self) -> None:
response = self.client.post(
"/api/v1/support/contracts/",
data=json.dumps({"code": "site.support.v1", "productId": self.product.id}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("code", response.json()["detail"].lower())
-125
View File
@@ -1,125 +0,0 @@
"""Проверка Product Support Token (SPEC-HUB-0011 §4).
HS256 JWT через stdlib (hmac/hashlib/base64) — без внешних зависимостей.
Целевой вариант RS256/EdDSA с JWKS — TODO (SPEC §4.3).
Token envelope claims: iss, aud, contract, iat, exp, jti, data.
Подпись проверяется по per-product secret (Product.support_token_secret, Fernet).
Возвращает разобранные claims; raw token не сохраняется и не логируется.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from datetime import UTC
from typing import Any
from chatballs.support import errors
SUPPORT_AUDIENCE = "chatballs.support"
@dataclass(frozen=True)
class TokenClaims:
iss: str
aud: str
contract: str
iat: int
exp: int
jti: str
data: dict[str, Any]
@property
def jti_hash(self) -> str:
return hashlib.sha256(self.jti.encode("utf-8")).hexdigest()
def _b64url_decode(segment: str) -> bytes:
padding = "=" * (-len(segment) % 4)
return base64.urlsafe_b64decode(segment + padding)
def verify_support_token(*, token: str, secret: str) -> TokenClaims:
"""Проверяет подпись и envelope claims. Поднимает SupportSessionError при ошибке.
Не проверяет product/contract/channel binding — это делает сервис по claims.
"""
if not token:
raise errors.SupportSessionError(errors.TOKEN_MISSING)
parts = token.split(".")
if len(parts) != 3:
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
header_b64, payload_b64, signature_b64 = parts
try:
header = json.loads(_b64url_decode(header_b64))
except (ValueError, json.JSONDecodeError):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID) from None
if header.get("alg") != "HS256":
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
if not secret:
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
given = _b64url_decode(signature_b64)
if not hmac.compare_digest(expected, given):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
try:
payload = json.loads(_b64url_decode(payload_b64))
except (ValueError, json.JSONDecodeError):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID) from None
return _claims_from_payload(payload)
def _claims_from_payload(payload: dict[str, Any]) -> TokenClaims:
if not isinstance(payload, dict):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
iss = payload.get("iss")
aud = payload.get("aud")
contract = payload.get("contract")
iat = payload.get("iat")
exp = payload.get("exp")
jti = payload.get("jti")
data = payload.get("data")
if not isinstance(iss, str) or not iss:
raise errors.SupportSessionError(errors.TOKEN_ISSUER_INVALID)
if aud != SUPPORT_AUDIENCE:
raise errors.SupportSessionError(errors.TOKEN_AUDIENCE_INVALID)
if not isinstance(contract, str) or not contract:
raise errors.SupportSessionError(errors.CONTRACT_NOT_FOUND)
if not isinstance(jti, str) or not jti:
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
if not isinstance(iat, int) or not isinstance(exp, int):
raise errors.SupportSessionError(errors.TOKEN_EXPIRED)
now = int(time.time())
if exp < now:
raise errors.SupportSessionError(errors.TOKEN_EXPIRED)
if not isinstance(data, dict):
raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID)
return TokenClaims(
iss=iss, aud=aud, contract=contract, iat=iat, exp=exp, jti=jti, data=data
)
def claims_datetimes(claims: TokenClaims):
"""iat/exp как aware datetime (для snapshot token_issued_at/expires_at)."""
from datetime import datetime
return (
datetime.fromtimestamp(claims.iat, tz=UTC),
datetime.fromtimestamp(claims.exp, tz=UTC),
)
-23
View File
@@ -1,23 +0,0 @@
from django.urls import include, path
from chatballs.support import views
urlpatterns = [
path("portals/", include("chatballs.support_portals.urls")),
path("contracts/", views.SupportContractListView.as_view(), name="support-contract-list"),
path(
"contracts/<int:contract_id>/",
views.SupportContractDetailView.as_view(),
name="support-contract-detail",
),
path(
"contracts/<int:contract_id>/status/",
views.SupportContractStatusView.as_view(),
name="support-contract-status",
),
path(
"snapshots/",
views.SupportSnapshotsBySubjectView.as_view(),
name="support-snapshots-by-subject",
),
]
-146
View File
@@ -1,146 +0,0 @@
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
from chatballs.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 chatballs.support.services import ContractInput, register_contract, set_contract_status
def _validation_error(error: ValidationError) -> Response:
detail = (
"; ".join(m for ms in error.message_dict.values() for m in ms)
if hasattr(error, "message_dict")
else "; ".join(error.messages)
)
return Response({"detail": detail}, status=400)
class _ManagerBase(APIView):
permission_classes = [HasCapability]
required_capability = "products.manage"
require_organization_scope = True
def _org(self, request: Request):
return request.tenant_context.organization
class SupportContractListView(_ManagerBase):
def get(self, request: Request) -> Response:
contracts = contracts_for_context(request.tenant_context)
return Response({"items": [support_contract_payload(c) for c in contracts]})
def post(self, request: Request) -> Response:
org = self._org(request)
try:
data = ContractInput(
code=str(request.data.get("code", "")),
product_id=int(request.data.get("productId", 0)),
status=str(request.data.get("status", "")) or "DRAFT",
schema_json=request.data.get("schema_json") or request.data.get("schemaJson") or {},
identity_mapping_json=request.data.get("identity_mapping_json")
or request.data.get("identityMappingJson")
or {},
operator_ui_json=request.data.get("operator_ui_json")
or request.data.get("operatorUiJson")
or {},
ai_context_json=request.data.get("ai_context_json")
or request.data.get("aiContextJson")
or {},
search_mapping_json=request.data.get("search_mapping_json")
or request.data.get("searchMappingJson")
or {},
sensitive_fields_json=request.data.get("sensitive_fields_json")
or request.data.get("sensitiveFieldsJson")
or {},
allowed_channel_ids=tuple(
int(x) for x in (request.data.get("allowedChannelIds") or [])
),
)
except (TypeError, ValueError):
return Response({"detail": "Invalid contract payload"}, status=400)
try:
contract = register_contract(context=request.tenant_context, data=data)
except ValidationError as error:
return _validation_error(error)
record_audit_event(
action="support.contract_registered",
actor=request.user,
organization=org,
object_type="ProductSupportContract",
object_id=str(contract.id),
request=request,
)
return Response({"contract": support_contract_payload(contract)}, status=201)
class SupportContractDetailView(_ManagerBase):
def get(self, request: Request, contract_id: int) -> Response:
try:
contract = contract_for_context(
context=request.tenant_context, contract_id=contract_id
)
except ProductSupportContract.DoesNotExist:
return Response({"detail": "Контракт не найден"}, status=404)
return Response({"contract": support_contract_payload(contract)})
class SupportContractStatusView(_ManagerBase):
def post(self, request: Request, contract_id: int) -> Response:
try:
contract = contract_for_context(
context=request.tenant_context, contract_id=contract_id
)
except ProductSupportContract.DoesNotExist:
return Response({"detail": "Контракт не найден"}, status=404)
status = str(request.data.get("status", ""))
try:
contract = set_contract_status(
context=request.tenant_context, contract=contract, status=status
)
except ValidationError as error:
return _validation_error(error)
record_audit_event(
action=f"support.contract_{status.lower()}",
actor=request.user,
organization=self._org(request),
object_type="ProductSupportContract",
object_id=str(contract.id),
request=request,
)
return Response({"contract": support_contract_payload(contract)})
class SupportSnapshotsBySubjectView(APIView):
# История обращений клиента для правой панели оператора (этап 1 — минимально).
permission_classes = [HasCapability]
required_capability = "support.view"
def get(self, request: Request) -> Response:
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 chatballs.products.models import Product
product = Product.objects.filter(
organization=request.tenant_context.organization, code=product_code
).first()
if product is None:
return Response({"detail": "Продукт не найден"}, status=404)
snapshots = snapshots_for_subject(
context=request.tenant_context,
product_id=product.id,
subject_key=subject_key,
)
snapshots = snapshots[:20]
return Response({"items": [support_identity_snapshot_payload(s) for s in snapshots]})
@@ -1,76 +0,0 @@
"""Stateless widget-credential для polling/send support-диалога виджетом.
После старта support-сессии (verify Product Support Token) виджету выдаётся
короткоживущий stateless-credential: виджет ходит с ним на poll/send endpoints,
не пере-верифицируя Product Support Token каждый запрос. Credential — HMAC-signed
JSON {conversation_id, snapshot_id, exp}, подписан SECRET_KEY. Без БД (нет отзыва —
компромисс для виджета; credential короткоживущий и привязан к conversation/snapshot).
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import TypedDict
from django.conf import settings
# Срок жизни credential длиннее Product Support Token (виджет работает долго),
# но ограничен — пере-выпуск через re-start сессии при истечении.
WIDGET_CREDENTIAL_TTL_SECONDS = 60 * 60 # 1 час
class WidgetClaims(TypedDict):
conversation_id: int
snapshot_id: int
exp: int
def _secret() -> bytes:
return (settings.SECRET_KEY or "").encode("utf-8")
def _sign(payload_b64: str) -> str:
return base64.urlsafe_b64encode(
hmac.new(_secret(), payload_b64.encode("utf-8"), hashlib.sha256).digest()
).rstrip(b"=").decode()
def issue_widget_credential(*, conversation_id: int, snapshot_id: int) -> str:
payload: WidgetClaims = {
"conversation_id": conversation_id,
"snapshot_id": snapshot_id,
"exp": int(time.time()) + WIDGET_CREDENTIAL_TTL_SECONDS,
}
payload_b64 = base64.urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode()
).rstrip(b"=").decode()
return f"{payload_b64}.{_sign(payload_b64)}"
def verify_widget_credential(credential: str) -> WidgetClaims | None:
if not credential or "." not in credential:
return None
payload_b64, signature = credential.rsplit(".", 1)
expected = _sign(payload_b64)
if not hmac.compare_digest(expected, signature):
return None
try:
padding = "=" * (-len(payload_b64) % 4)
raw = base64.urlsafe_b64decode(payload_b64 + padding)
claims = json.loads(raw)
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(claims, dict):
return None
exp = claims.get("exp")
if not isinstance(exp, int) or exp < int(time.time()):
return None
conversation_id = claims.get("conversation_id")
snapshot_id = claims.get("snapshot_id")
if not isinstance(conversation_id, int) or not isinstance(snapshot_id, int):
return None
return {"conversation_id": conversation_id, "snapshot_id": snapshot_id, "exp": exp}
@@ -6,14 +6,12 @@ from chatballs.support_portals.models import (
PortalArticleRevision,
PortalCategory,
SupportPortal,
SupportPortalProduct,
)
admin.site.register(
(
SupportPortal,
SupportPortalProduct,
PortalCategory,
PortalArticle,
PortalArticleRevision,
@@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("channels", "0005_enforce_policy_invariants"),
("products", "0010_alter_marketplacepublication_organization_and_more"),
("support_portals", "0001_portals"),
]
@@ -122,58 +121,6 @@ class Migration(migrations.Migration):
],
options={"ordering": ["-created_at"]},
),
migrations.CreateModel(
name="SupportPortalProduct",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sort_order", models.PositiveIntegerField(default=0)),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="identity.organization",
),
),
(
"portal",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="product_links",
to="support_portals.supportportal",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="support_portal_links",
to="products.product",
),
),
(
"support_channel",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="support_portal_routes",
to="channels.channel",
),
),
],
options={"ordering": ["sort_order", "product__name", "id"]},
),
migrations.AddField(
model_name="supportportal",
name="products",
field=models.ManyToManyField(
blank=True,
related_name="support_portals",
through="support_portals.SupportPortalProduct",
to="products.product",
),
),
migrations.AddConstraint(
model_name="portalarticle",
constraint=models.UniqueConstraint(
@@ -202,11 +149,4 @@ class Migration(migrations.Migration):
name="support_portal_article_revision_positive",
),
),
migrations.AddConstraint(
model_name="supportportalproduct",
constraint=models.UniqueConstraint(
fields=("portal", "product"),
name="uniq_support_portal_product",
),
),
]
@@ -4,7 +4,6 @@ from django.db import migrations, models
def backfill_widget_references(apps, schema_editor):
SupportPortal = apps.get_model("support_portals", "SupportPortal")
SupportPortalProduct = apps.get_model("support_portals", "SupportPortalProduct")
WebChatWidget = apps.get_model("webchat", "WebChatWidget")
for portal in SupportPortal.objects.exclude(widget_channel_id=None).iterator():
@@ -18,17 +17,6 @@ def backfill_widget_references(apps, schema_editor):
portal.widget_id = matches[0].id
portal.save(update_fields=["widget"])
for link in SupportPortalProduct.objects.exclude(support_channel_id=None).iterator():
matches = list(
WebChatWidget.objects.filter(
integration__channel_id=link.support_channel_id,
mode="AUTHENTICATED_PRODUCT",
).order_by("id")[:2]
)
if len(matches) == 1:
link.support_widget_id = matches[0].id
link.save(update_fields=["support_widget"])
class Migration(migrations.Migration):
dependencies = [
@@ -41,10 +29,5 @@ class Migration(migrations.Migration):
name="widget",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="support_portals", to="webchat.webchatwidget"),
),
migrations.AddField(
model_name="supportportalproduct",
name="support_widget",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="support_portal_routes", to="webchat.webchatwidget"),
),
migrations.RunPython(backfill_widget_references, migrations.RunPython.noop),
]
@@ -61,12 +61,6 @@ class SupportPortal(TenantRelationModel):
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
products = models.ManyToManyField(
"products.Product",
through="SupportPortalProduct",
related_name="support_portals",
blank=True,
)
class Meta:
ordering = ["name", "id"]
@@ -104,68 +98,6 @@ class SupportPortal(TenantRelationModel):
validate_portal_widget(self)
class SupportPortalProduct(TenantRelationModel):
"""Продукт портала и его authenticated support-маршрут."""
tenant_relation_fields = ("portal", "product", "support_channel", "support_widget")
portal = models.ForeignKey(
SupportPortal,
on_delete=models.CASCADE,
related_name="product_links",
)
product = models.ForeignKey(
"products.Product",
on_delete=models.PROTECT,
related_name="support_portal_links",
)
support_channel = models.ForeignKey(
"channels.Channel",
on_delete=models.PROTECT,
related_name="support_portal_routes",
null=True,
blank=True,
)
support_widget = models.ForeignKey(
"webchat.WebChatWidget",
on_delete=models.PROTECT,
related_name="support_portal_routes",
null=True,
blank=True,
)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "product__name", "id"]
constraints = [
models.UniqueConstraint(
fields=["portal", "product"],
name="uniq_support_portal_product",
),
]
def clean(self) -> None:
super().clean()
from chatballs.support_portals.widget_validation import (
validate_product_support_widget,
)
validate_product_support_widget(self)
if self.support_channel_id is None:
return
channel = self.support_channel
if channel.product_id != self.product_id:
raise ValidationError(
{"support_channel": "Support channel must belong to the linked product"}
)
if (
not channel.requires_authenticated_product_identity
or channel.allow_anonymous_sessions
):
raise ValidationError(
{"support_channel": "Portal support route must require product identity"}
)
class PortalCategory(TenantRelationModel):
tenant_relation_fields = ("portal", "parent")
portal = models.ForeignKey(
@@ -4,14 +4,9 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
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 chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.statuses import PortalStatus
from chatballs.support_portals.themes import (
DEFAULT_PORTAL_THEME,
@@ -22,11 +17,7 @@ from chatballs.support_portals.themes import (
validate_theme_settings,
)
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
)
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
@dataclass(frozen=True)
@@ -100,13 +91,11 @@ def _widget(
"integration__channel",
).filter(
organization=context.organization,
mode=WebChatWidgetMode.ANONYMOUS,
status=WebChatWidgetStatus.PUBLISHED,
integration__provider=IntegrationProvider.WEB,
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel__is_active=True,
integration__channel__requires_authenticated_product_identity=False,
integration__channel__allow_anonymous_sessions=True,
)
if widget_id is not None:
@@ -147,107 +136,6 @@ def set_portal_status(
return portal
@transaction.atomic
def replace_product_links(
*, context: TenantContext, portal: SupportPortal, links: list[dict]
) -> SupportPortal:
_check_tenant(context, portal)
if portal.status == PortalStatus.ARCHIVED:
raise ValidationError(
{"portal": "Восстановите портал, чтобы изменить его продукты"}
)
product_ids = [int(item.get("productId", 0)) for item in links]
channel_ids = [
int(item["supportChannelId"])
for item in links
if item.get("supportChannelId") is not None
]
widget_ids = [
int(item["supportWidgetId"])
for item in links
if item.get("supportWidgetId") is not None
]
if len(product_ids) != len(set(product_ids)):
raise ValidationError({"products": "Один продукт нельзя добавить дважды"})
products = {
item.id: item
for item in Product.objects.filter(
organization=context.organization,
id__in=product_ids,
)
}
channels = {
item.id: item
for item in Channel.objects.filter(
organization=context.organization,
id__in=channel_ids,
)
}
widgets = {
item.id: item
for item in WebChatWidget.objects.select_related(
"integration",
"integration__channel",
).filter(
organization=context.organization,
id__in=widget_ids,
mode=WebChatWidgetMode.AUTHENTICATED_PRODUCT,
status=WebChatWidgetStatus.PUBLISHED,
integration__provider=IntegrationProvider.WEB,
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel__is_active=True,
)
}
legacy_widgets = list(
WebChatWidget.objects.select_related("integration__channel").filter(
organization=context.organization,
mode=WebChatWidgetMode.AUTHENTICATED_PRODUCT,
status=WebChatWidgetStatus.PUBLISHED,
integration__provider=IntegrationProvider.WEB,
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel_id__in=channel_ids,
)
)
if (
len(products) != len(product_ids)
or len(channels) != len(set(channel_ids))
or len(widgets) != len(set(widget_ids))
):
raise ValidationError({"products": "Продукт или канал поддержки не найден"})
replacements = []
for position, item in enumerate(links):
channel_id = item.get("supportChannelId")
widget_id = item.get("supportWidgetId")
widget = widgets.get(int(widget_id)) if widget_id is not None else None
if widget is None and channel_id is not None:
candidates = [
candidate
for candidate in legacy_widgets
if candidate.integration.channel_id == int(channel_id)
]
widget = candidates[0] if len(candidates) == 1 else None
if widget is None:
raise ValidationError(
{"products": "Для канала нужен один опубликованный Web-виджет"}
)
channel = widget.integration.channel if widget is not None else None
link = SupportPortalProduct(
organization=context.organization,
portal=portal,
product=products[int(item["productId"])],
support_channel=channel,
support_widget=widget,
sort_order=int(item.get("sortOrder", position)),
)
link.full_clean()
replacements.append(link)
portal.product_links.all().delete()
SupportPortalProduct.objects.bulk_create(replacements)
return portal
def _check_tenant(context: TenantContext, portal: SupportPortal) -> None:
if portal.organization_id != context.organization_id:
raise ValidationError({"portal": "Портал принадлежит другой организации"})
@@ -16,7 +16,6 @@ from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.portal_services import (
PortalInput,
create_portal,
replace_product_links,
set_portal_status,
update_portal,
)
@@ -30,11 +29,7 @@ from chatballs.support_portals.themes import (
DEFAULT_PORTAL_THEME,
PortalThemeScheme,
)
from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
WebChatWidgetStatus,
)
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
from chatballs.webchat.widgets import widget_payload
@@ -212,30 +207,8 @@ class PortalStatusView(PortalBaseView):
return Response({"portal": portal_payload(portal)})
class PortalProductsView(PortalBaseView):
def put(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
links = request.data.get("items")
if not isinstance(links, list):
return Response({"detail": "items должен быть списком"}, status=400)
try:
replace_product_links(
context=request.tenant_context,
portal=portal,
links=links,
)
except (ValidationError, TypeError, ValueError) as error:
if isinstance(error, ValidationError):
return validation_response(error)
return Response({"detail": "Некорректная привязка продукта"}, status=400)
portal = portal_for_context(request.tenant_context, portal.id)
return Response({"portal": portal_payload(portal)})
class PortalSupportChannelsView(PortalBaseView):
"""Portal-scoped widget options available to support operators."""
class PortalWidgetOptionsView(PortalBaseView):
"""Анонимные веб-виджеты, доступные порталу."""
def get(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
@@ -245,45 +218,19 @@ class PortalSupportChannelsView(PortalBaseView):
WebChatWidget.objects.select_related(
"integration",
"integration__channel",
"integration__channel__product",
)
.filter(
organization=request.tenant_context.organization,
mode=WebChatWidgetMode.AUTHENTICATED_PRODUCT,
status=WebChatWidgetStatus.PUBLISHED,
integration__provider=IntegrationProvider.WEB,
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel__product__isnull=False,
integration__channel__is_active=True,
)
.order_by("integration__channel__product__name", "name", "id")
)
anonymous_widgets = (
WebChatWidget.objects.select_related(
"integration",
"integration__channel",
"integration__channel__product",
)
.filter(
organization=request.tenant_context.organization,
mode=WebChatWidgetMode.ANONYMOUS,
status=WebChatWidgetStatus.PUBLISHED,
integration__provider=IntegrationProvider.WEB,
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel__is_active=True,
integration__channel__requires_authenticated_product_identity=False,
integration__channel__allow_anonymous_sessions=True,
)
.order_by("name", "id")
)
return Response(
{
"items": [widget_payload(widget) for widget in widgets],
"widgetItems": [widget_payload(widget) for widget in anonymous_widgets],
}
)
return Response({"items": [widget_payload(widget) for widget in widgets]})
class PortalDomainView(PortalBaseView):
@@ -38,14 +38,7 @@ class PublicPortalView(APIView):
"widget",
"widget__integration",
"widget__integration__channel",
).prefetch_related(
"categories",
"product_links__product",
"product_links__support_channel",
"product_links__support_widget",
"product_links__support_widget__integration",
"product_links__support_widget__integration__channel",
)
).prefetch_related("categories")
.filter(
id=route.resource_id,
organization=organization,
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more