mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🔥 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:
1 parent
41a4b0dd84
commit
146052916b
234 files changed
+6883
-7895
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)␍
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = "Коротко."
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
-5
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)␍
|
||||
␍
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
-7
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
-1
@@ -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):
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
-118
@@ -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)]
|
||||
-21
@@ -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'),
|
||||
),
|
||||
]
|
||||
-26
@@ -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"),
|
||||
]
|
||||
-35
@@ -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'),
|
||||
),
|
||||
]
|
||||
-85
@@ -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 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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"], [])
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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.
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
Reference in new issue
Block a user