🔥 refactor(api)!: снос старых роутов /channels/ и /ai/agents/ — единственный API агента /agents/

Карточка агента (/agents/) — единственный HTTP-слой поверх Channel+AIAgent
(ADR-HUB-0041 §4). Удалены дублирующие роуты channels/ (list/detail/
connections/counters) и ai/agents/ (list/detail/update/activate/deactivate)
вместе с views/serializers/api_inputs и мёртвым сервисным кодом каналов
(create_channel, delete_channel, filter_channels). Тест-чат перенесён в
AgentCardTestChatView (/agents/<id>/test-chat/). Bulk-роут
ai/agents/<aiAgentId>/knowledge/select-category/ сохранён — он адресует
AIAgent, как и knowledge/bulk/agent/.

Фронт: bulk-attach знаний и статей портала берёт агентов из /agents/
(id для bulk — aiAgentId карточки), AgentLinkOption без channelName.

Тесты старых роутов удалены; уникальное покрытие портировано на карточки
(активация без провайдера, семантика knowledgeIds, атомарный откат PATCH,
payload статей портала).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-05 14:14:56 +03:00
1 parent ce8c753357
commit 3c2d6ea511
24 files changed
+151 -1734

No files matched your search

-4
View File
@@ -32,10 +32,6 @@ urlpatterns = [
"api/v1/organizations/<uuid:organization_public_id>/integrations/",
include("hub_platform.integrations.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/channels/",
include("hub_platform.channels.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/conversations/",
include("hub_platform.conversations.urls"),
@@ -3,7 +3,6 @@
from django.urls import path
from hub_platform.ai import agent_card_views as views
from hub_platform.channels.views import ChannelTestChatView
urlpatterns = [
path("", views.AgentCardListView.as_view(), name="agent-card-list"),
@@ -28,11 +27,9 @@ urlpatterns = [
views.AgentCardConnectionDetailView.as_view(),
name="agent-card-connection-detail",
),
# Тестовый чат исполняет агента канала; вьюха принимает channel_id — id
# карточки и канала совпадают по построению.
path(
"<int:channel_id>/test-chat/",
ChannelTestChatView.as_view(),
"<int:agent_id>/test-chat/",
views.AgentCardTestChatView.as_view(),
name="agent-card-test-chat",
),
]
@@ -16,9 +16,12 @@ from hub_platform.ai.agent_card import (
set_agent_card_active,
update_agent_card,
)
from hub_platform.ai.provider.base import ProviderError
from hub_platform.api.permissions import HasCapability
from hub_platform.channels import services as channel_services
from hub_platform.channels.models import Channel
from hub_platform.channels.runtime import run_channel_turn
from hub_platform.channels.selectors import channel_for_context
from hub_platform.identity.audit import record_audit_event
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
@@ -172,6 +175,41 @@ class AgentCardDeactivateView(_AgentCardStatusView):
target_active = False
class AgentCardTestChatView(APIView):
permission_classes = [HasCapability]
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
required_capability = "ai.manage"
require_organization_scope = True
def post(self, request: Request, agent_id: int) -> Response:
try:
channel = channel_for_context(
context=request.tenant_context,
channel_id=agent_id,
capability="ai.view",
)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
message = str(request.data.get("message", "")).strip()
if not message:
return Response({"detail": "Пустое сообщение"}, status=400)
history = request.data.get("history") or []
if not isinstance(history, list):
return Response({"detail": "history must be a list"}, status=400)
try:
result = run_channel_turn(channel=channel, message=message, history=history)
except ProviderError as error:
return Response({"detail": f"Ошибка провайдера: {error}"}, status=502)
return Response(
{
"reply": result.text,
"model": result.model,
"promptTokens": result.prompt_tokens,
"completionTokens": result.completion_tokens,
}
)
class AgentCardConnectionsView(APIView):
permission_classes = [HasCapability]
required_capability = "integrations.manage"
-204
View File
@@ -1,204 +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 hub_platform.ai.models import AIAgent
from hub_platform.ai.selectors import (
agent_for_employee,
agents_for_employee,
channel_for_ai_capability,
)
from hub_platform.ai.serializers import agent_payload
from hub_platform.ai.services import (
AgentCreateInput,
AgentInput,
create_agent,
set_agent_active,
update_agent,
)
from hub_platform.api.permissions import HasCapability
from hub_platform.channels.models import Channel
from hub_platform.identity.audit import record_audit_event
def _agent_input(body: dict[str, object], *, current: AIAgent) -> AgentInput:
model_params = body.get("modelParams", current.model_params)
allowed_tools = body.get("allowedTools", current.allowed_tools)
limits = body.get("limits", current.limits)
knowledge_ids = body.get("knowledgeIds")
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": "Object required"})
if not isinstance(allowed_tools, list):
raise ValidationError({"allowedTools": "List required"})
if not isinstance(limits, dict):
raise ValidationError({"limits": "Object required"})
if knowledge_ids is not None and (
not isinstance(knowledge_ids, list)
or not all(isinstance(item, int) for item in knowledge_ids)
):
raise ValidationError({"knowledgeIds": "List of ids required"})
provider_integration_id = body.get(
"providerIntegrationId", current.provider_integration_id
)
if provider_integration_id is not None and not isinstance(provider_integration_id, int):
raise ValidationError({"providerIntegrationId": "Integer id required"})
return AgentInput(
name=str(body.get("name", current.name)).strip() or current.name,
provider_integration_id=provider_integration_id,
model_params=model_params,
allowed_tools=allowed_tools,
limits=limits,
persona=str(body.get("persona", current.persona)),
tone=str(body.get("tone", current.tone)),
instructions=str(body.get("instructions", current.instructions)),
knowledge_ids=knowledge_ids,
)
def _validation_error(error: ValidationError) -> Response:
detail = (
"; ".join(message for messages in error.message_dict.values() for message in messages)
if hasattr(error, "message_dict")
else "; ".join(error.messages)
)
return Response({"detail": detail}, status=400)
class AIAgentListView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "ai.view", "POST": "ai.manage"}
def get(self, request: Request) -> Response:
agents = agents_for_employee(
context=request.tenant_context,
capability="ai.view",
)
return Response({"items": [agent_payload(agent) for agent in agents]})
def post(self, request: Request) -> Response:
knowledge_ids = request.data.get("knowledgeIds", [])
if not isinstance(knowledge_ids, list) or not all(
isinstance(item, int) for item in knowledge_ids
):
return Response({"detail": "knowledgeIds must be a list of ids"}, status=400)
channel_code = str(request.data.get("channel", "")).strip()
try:
channel_for_ai_capability(
context=request.tenant_context,
channel_code=channel_code,
capability="ai.manage",
)
except Channel.DoesNotExist:
return Response({"detail": "Channel not found"}, status=404)
try:
agent = create_agent(
context=request.tenant_context,
data=AgentCreateInput(
channel_code=channel_code,
provider_integration_id=request.data.get("providerIntegrationId"),
persona=str(request.data.get("persona", "")),
tone=str(request.data.get("tone", "")),
instructions=str(request.data.get("instructions", "")),
knowledge_ids=knowledge_ids,
),
)
except ValidationError as error:
return _validation_error(error)
record_audit_event(
action="ai.agent_created",
actor=request.user,
organization=request.tenant_context.organization,
object_type="AIAgent",
object_id=str(agent.id),
request=request,
)
return Response({"agent": agent_payload(agent)}, status=201)
class AIAgentDetailView(APIView):
permission_classes = [HasCapability]
required_capability = "ai.view"
def get(self, request: Request, agent_id: int) -> Response:
try:
agent = agent_for_employee(
context=request.tenant_context,
agent_id=agent_id,
capability="ai.view",
)
except AIAgent.DoesNotExist:
return Response({"detail": "Agent not found"}, status=404)
return Response({"agent": agent_payload(agent)})
class AIAgentUpdateView(APIView):
permission_classes = [HasCapability]
required_capability = "ai.manage"
def patch(self, request: Request, agent_id: int) -> Response:
try:
agent = agent_for_employee(
context=request.tenant_context,
agent_id=agent_id,
capability="ai.manage",
)
except AIAgent.DoesNotExist:
return Response({"detail": "Agent not found"}, status=404)
try:
agent = update_agent(
context=request.tenant_context,
agent=agent,
data=_agent_input(request.data, current=agent),
)
except ValidationError as error:
return _validation_error(error)
record_audit_event(
action="ai.agent_updated",
actor=request.user,
organization=request.tenant_context.organization,
object_type="AIAgent",
object_id=str(agent.id),
request=request,
)
return Response(
{
"agent": agent_payload(
agent_for_employee(
context=request.tenant_context,
agent_id=agent_id,
capability="ai.manage",
)
)
}
)
class _AIAgentStatusView(APIView):
permission_classes = [HasCapability]
required_capability = "ai.manage"
target_active: bool
def post(self, request: Request, agent_id: int) -> Response:
try:
agent = agent_for_employee(
context=request.tenant_context,
agent_id=agent_id,
capability="ai.manage",
)
except AIAgent.DoesNotExist:
return Response({"detail": "Agent not found"}, status=404)
try:
agent = set_agent_active(
context=request.tenant_context,
agent=agent,
is_active=self.target_active,
)
except ValidationError as error:
return _validation_error(error)
return Response({"agent": agent_payload(agent)})
class AIAgentActivateView(_AIAgentStatusView):
target_active = True
class AIAgentDeactivateView(_AIAgentStatusView):
target_active = False
-12
View File
@@ -38,18 +38,6 @@ def agent_for_employee(*, context: TenantContext, agent_id: int, capability: str
return agents_for_employee(context=context, capability=capability).get(id=agent_id)
def channel_for_ai_capability(
*, context: TenantContext, channel_code: str, capability: str
) -> Channel:
queryset = Channel.objects.filter(
organization_id=context.organization_id,
code=channel_code,
)
if has_capability_any_scope(context.membership, capability):
return queryset.get()
return queryset.none().get()
def knowledge_for_context(context: TenantContext) -> QuerySet[Knowledge]:
return knowledge_for_employee(context=context)
@@ -1,5 +1,4 @@
from hub_platform.ai.models import (
AIAgent,
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
@@ -7,20 +6,6 @@ from hub_platform.ai.models import (
from hub_platform.support_portals.addressing import article_public_url
def _channel_ref(channel) -> dict[str, object]:
return {
"id": channel.id,
"code": channel.code,
"name": channel.name,
"product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None,
"group": (
{"id": channel.group_id, "name": channel.group.name}
if channel.group_id
else None
),
}
def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]:
return {
"id": attachment.id,
@@ -79,32 +64,3 @@ def agent_portal_article_payload(article) -> dict[str, object]:
"portal": {"id": article.portal_id, "name": article.portal.name},
"publicUrl": article_public_url(article),
}
def agent_payload(agent: AIAgent) -> dict[str, object]:
return {
"id": agent.id,
"channel": _channel_ref(agent.channel),
"name": agent.name,
"isActive": agent.is_active,
"status": agent.status,
"model": agent.model,
# BYOK-провайдер принадлежит агенту (SPEC-HUB-0027 §9).
"providerIntegrationId": agent.provider_integration_id,
"modelParams": agent.model_params,
"allowedTools": agent.allowed_tools,
"limits": agent.limits,
"persona": agent.persona,
"tone": agent.tone,
"instructions": agent.instructions,
"knowledge": [
{"id": knowledge.id, "title": knowledge.title, "isEnabled": knowledge.is_enabled}
for knowledge in agent.knowledge_items.all()
],
"portalArticles": [
agent_portal_article_payload(article)
for article in agent.portal_articles.all()
],
"createdAt": agent.created_at.isoformat(),
"updatedAt": agent.updated_at.isoformat(),
}
@@ -213,7 +213,7 @@ class AgentPortalArticleLinkApiTests(AgentAttachmentTestCase):
article = self.article(self.portal(), title="Refund policy")
self.support_agent.portal_articles.add(article)
response = self.client.get(f"/api/v1/ai/agents/{self.support_agent.id}/")
response = self.client.get(f"/api/v1/agents/{self.support_channel.id}/")
self.assertEqual(response.status_code, 200)
articles = response.json()["agent"]["portalArticles"]
@@ -175,6 +175,76 @@ class AgentCardUpdateTests(AgentCardTestCase):
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertEqual(agent.persona, "")
def test_knowledge_selection_semantics(self) -> None:
# Пустой список снимает выбор; отсутствие ключа — не трогает.
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import Knowledge
knowledge = Knowledge.objects.create(
organization=self.organization,
category=ensure_uncategorized_category(self.organization),
title="FAQ",
content="v1",
)
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertEqual(self.patch(knowledgeIds=[knowledge.id]).status_code, 200)
self.assertEqual(agent.knowledge_items.count(), 1)
self.patch(name="Renamed")
self.assertEqual(agent.knowledge_items.count(), 1)
self.patch(knowledgeIds=[])
self.assertEqual(agent.knowledge_items.count(), 0)
class AgentCardActivationTests(AgentCardTestCase):
def setUp(self) -> None:
super().setUp()
self.card = self.create_agent().json()["agent"]
def _byok_integration(self) -> Integration:
from hub_platform.integrations.models import IntegrationProvider
from hub_platform.integrations.services import IntegrationInput, create_integration
from hub_platform.testing import system_tenant_context
return create_integration(
context=system_tenant_context(self.organization),
data=IntegrationInput(
provider=IntegrationProvider.OPENROUTER,
name="BYOK",
secret="sk-byok",
config={"baseUrl": "https://openrouter.ai/api/v1", "defaultModel": "byok-model"},
),
)
def test_activation_without_provider_integration_is_rejected(self) -> None:
# Активация требует выбранного провайдера организации (ADR-HUB-0042 §2);
# деактивация свободна.
response = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(response.status_code, 400)
self.assertIn("провайдера", response.json()["detail"])
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertNotEqual(agent.status, AIAgentStatus.ACTIVE)
def test_activates_and_deactivates_with_provider(self) -> None:
integration = self._byok_integration()
patched = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps({"providerIntegrationId": integration.id}),
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(activated.status_code, 200)
self.assertEqual(activated.json()["agent"]["aiStatus"], AIAgentStatus.ACTIVE)
deactivated = self.client.post(f"/api/v1/agents/{self.card['id']}/deactivate/")
self.assertEqual(deactivated.status_code, 200)
self.assertEqual(deactivated.json()["agent"]["aiStatus"], AIAgentStatus.DISABLED)
class AgentCardDeleteTests(AgentCardTestCase):
def test_deletes_card_with_agent(self) -> None:
@@ -51,33 +51,34 @@ class AgentKnowledgeAssignmentTests(TestCase):
password="temporary-password",
)
def _create(self, channel: Channel, knowledge_ids: list[int]):
return self.client.post(
"/api/v1/ai/agents/",
data=json.dumps({"channel": channel.code, "knowledgeIds": knowledge_ids}),
content_type="application/json",
def _create(self, channel: Channel, knowledge_ids: list[int]) -> AIAgent:
from hub_platform.ai.services import AgentCreateInput, create_agent
return create_agent(
context=self.context,
data=AgentCreateInput(
channel_code=channel.code,
provider_integration_id=None,
persona="",
tone="",
instructions="",
knowledge_ids=knowledge_ids,
),
)
def test_create_accepts_organization_knowledge(self) -> None:
response = self._create(
self.channel,
[self.shared.id, self.second.id],
)
agent = self._create(self.channel, [self.shared.id, self.second.id])
self.assertEqual(response.status_code, 201)
agent = AIAgent.objects.get(channel=self.channel)
self.assertEqual(
set(agent.knowledge_items.values_list("id", flat=True)),
{self.shared.id, self.second.id},
)
def test_create_rejects_selection_with_unknown_knowledge_id(self) -> None:
response = self._create(
self.channel,
[self.shared.id, 999999],
)
from django.core.exceptions import ValidationError
self.assertEqual(response.status_code, 400)
with self.assertRaises(ValidationError):
self._create(self.channel, [self.shared.id, 999999])
self.assertFalse(AIAgent.objects.filter(channel=self.channel).exists())
def test_update_rolls_back_agent_fields_and_selection_on_invalid_id(self) -> None:
@@ -88,8 +89,9 @@ class AgentKnowledgeAssignmentTests(TestCase):
)
agent.knowledge_items.add(self.shared)
# PATCH карточки атомарен: канал и AI-поля откатываются вместе.
response = self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
f"/api/v1/agents/{self.channel.id}/",
data=json.dumps(
{
"name": "Changed",
@@ -101,7 +103,9 @@ class AgentKnowledgeAssignmentTests(TestCase):
self.assertEqual(response.status_code, 400)
agent.refresh_from_db()
self.channel.refresh_from_db()
self.assertEqual(agent.name, "Original")
self.assertEqual(self.channel.name, "Org")
self.assertEqual(
list(agent.knowledge_items.values_list("id", flat=True)),
[self.shared.id],
@@ -74,18 +74,18 @@ class KnowledgePolicyApiTests(KnowledgePolicyTestBase):
)
agent = AIAgent.objects.create(channel=channel, name="Agent")
response = self.client.get("/api/v1/ai/agents/")
response = self.client.get("/api/v1/agents/")
self.assertEqual(response.status_code, 200)
self.assertEqual(
[item["id"] for item in response.json()["items"]],
[item["aiAgentId"] for item in response.json()["items"]],
[agent.id],
)
detail = self.client.get(f"/api/v1/ai/agents/{agent.id}/")
detail = self.client.get(f"/api/v1/agents/{channel.id}/")
self.assertEqual(detail.status_code, 200)
self.client.force_authenticate(self.employee.user)
self.assertEqual(self.client.get("/api/v1/ai/agents/").status_code, 403)
self.assertEqual(self.client.get("/api/v1/agents/").status_code, 403)
self.assertEqual(
self.client.get(f"/api/v1/ai/agents/{agent.id}/").status_code, 403
self.client.get(f"/api/v1/agents/{channel.id}/").status_code, 403
)
@@ -8,7 +8,6 @@ from hub_platform.ai.selectors import (
agents_for_employee,
apply_knowledge_filters,
category_tree_for_employee,
channel_for_ai_capability,
knowledge_available_to_agent,
knowledge_for_employee,
)
@@ -134,17 +133,3 @@ class KnowledgeSelectorTests(KnowledgePolicyTestBase):
agent_id=agent.id,
capability="ai.view",
)
self.assertEqual(
channel_for_ai_capability(
context=self.admin_context,
channel_code=channel.code,
capability="ai.manage",
),
channel,
)
with self.assertRaises(Channel.DoesNotExist):
channel_for_ai_capability(
context=self.employee_context,
channel_code=channel.code,
capability="ai.manage",
)
+2 -172
View File
@@ -28,30 +28,6 @@ def make_channel_with_agent(organization, *, code, name, product=None, model="op
return channel, agent
def seed_sales_channels(organization):
firepage = Product.objects.get(organization=organization, code="firepage")
foxray = Product.objects.get(organization=organization, code="foxray")
make_channel_with_agent(organization, code="firepage-sales", name="FirePage — продажи", product=firepage)
make_channel_with_agent(organization, code="foxray-sales", name="FoxRay — продажи", product=foxray)
def _byok_integration(organization, *, default_model="byok-model"):
"""LLM-интеграция организации: единственный источник провайдера агента
после удаления managed-режима (ADR-HUB-0042 §3)."""
from hub_platform.integrations.models import IntegrationProvider
from hub_platform.integrations.services import IntegrationInput, create_integration
return create_integration(
context=system_tenant_context(organization),
data=IntegrationInput(
provider=IntegrationProvider.OPENROUTER,
name="BYOK",
secret="sk-byok",
config={"baseUrl": "https://openrouter.ai/api/v1", "defaultModel": default_model},
),
)
def make_knowledge(organization, *, title, content=""):
return Knowledge.objects.create(
organization=organization,
@@ -93,148 +69,6 @@ class AIAgentInvariantTests(TestCase):
self.assertFalse(AIAgent.objects.filter(channel__product=product).exists())
class AIAgentApiTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
seed_sales_channels(self.organization)
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_owner_lists_agents(self) -> None:
response = self.client.get("/api/v1/ai/agents/")
self.assertEqual(response.status_code, 200)
codes = {item["channel"]["product"]["code"] for item in response.json()["items"]}
self.assertEqual(codes, {"firepage", "foxray"})
def test_owner_creates_agent_with_instructions_and_knowledge(self) -> None:
academy = Product.objects.create(organization=self.organization, code="academy", name="Academy")
Channel.objects.create(organization=self.organization, code="academy-sales", name="Academy", product=academy)
knowledge = make_knowledge(self.organization, title="FAQ", content="v1")
response = self.client.post(
"/api/v1/ai/agents/",
data=json.dumps(
{
"channel": "academy-sales",
"persona": "Ты — ассистент Academy.",
"tone": "Коротко и по делу.",
"instructions": "Отвечай по делу.",
"knowledgeIds": [knowledge.id],
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
body = response.json()["agent"]
self.assertEqual(body["channel"]["code"], "academy-sales")
self.assertFalse(body["isActive"])
self.assertEqual(body["persona"], "Ты — ассистент Academy.")
self.assertEqual([item["id"] for item in body["knowledge"]], [knowledge.id])
def test_owner_cannot_create_second_agent_for_channel(self) -> None:
response = self.client.post(
"/api/v1/ai/agents/",
data=json.dumps({"channel": "firepage-sales", "knowledgeIds": []}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_owner_updates_agent_provider_and_instructions(self) -> None:
agent = AIAgent.objects.get(channel__code="firepage-sales")
integration = _byok_integration(self.organization)
response = self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps(
{
"providerIntegrationId": integration.id,
"modelParams": {"temperature": 0.3},
"tone": "Дружелюбно.",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(response.json()["agent"]["providerIntegrationId"], integration.id)
self.assertNotIn("credentialMode", response.json()["agent"])
agent.refresh_from_db()
self.assertEqual(agent.provider_integration_id, integration.id)
self.assertEqual(agent.model, "byok-model")
self.assertEqual(agent.model_params, {"temperature": 0.3})
self.assertEqual(agent.tone, "Дружелюбно.")
def test_owner_updates_agent_knowledge_selection(self) -> None:
agent = AIAgent.objects.get(channel__code="firepage-sales")
knowledge = make_knowledge(self.organization, title="FAQ", content="v1")
response = self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"knowledgeIds": [knowledge.id]}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(list(agent.knowledge_items.values_list("id", flat=True)), [knowledge.id])
# Пустой список снимает выбор; отсутствие ключа — не трогает.
self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"name": "Renamed"}),
content_type="application/json",
)
self.assertEqual(agent.knowledge_items.count(), 1)
self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"knowledgeIds": []}),
content_type="application/json",
)
self.assertEqual(agent.knowledge_items.count(), 0)
def test_owner_deactivates_and_activates_agent(self) -> None:
agent = AIAgent.objects.get(channel__code="foxray-sales")
integration = _byok_integration(self.organization)
agent.provider_integration = integration
agent.save(update_fields=["provider_integration"])
deactivated = self.client.post(f"/api/v1/ai/agents/{agent.id}/deactivate/")
self.assertEqual(deactivated.status_code, 200)
self.assertFalse(deactivated.json()["agent"]["isActive"])
activated = self.client.post(f"/api/v1/ai/agents/{agent.id}/activate/")
self.assertEqual(activated.status_code, 200)
self.assertTrue(activated.json()["agent"]["isActive"])
def test_activation_without_provider_integration_is_rejected(self) -> None:
# Активация требует выбранного провайдера организации (ADR-HUB-0042 §2);
# деактивация свободна.
agent = AIAgent.objects.get(channel__code="foxray-sales")
self.client.post(f"/api/v1/ai/agents/{agent.id}/deactivate/")
response = self.client.post(f"/api/v1/ai/agents/{agent.id}/activate/")
self.assertEqual(response.status_code, 400)
self.assertIn("провайдера", response.json()["detail"])
agent.refresh_from_db()
self.assertFalse(agent.is_active)
def test_update_rejects_invalid_model_params(self) -> None:
agent = AIAgent.objects.get(channel__code="firepage-sales")
response = self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"modelParams": "not-an-object"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
class AIAgentPermissionTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
@@ -248,10 +82,6 @@ class AIAgentPermissionTests(TestCase):
self.client = APIClient()
self.client.login(username="operator@edevs.tech", password="operator-password")
def test_operator_cannot_access_agents(self) -> None:
response = self.client.get("/api/v1/ai/agents/")
self.assertEqual(response.status_code, 403)
def test_operator_cannot_access_knowledge(self) -> None:
response = self.client.get("/api/v1/ai/knowledge/")
self.assertEqual(response.status_code, 403)
@@ -713,12 +543,12 @@ class AgentRuntimeTests(TestCase):
with self.assertRaises(ProviderError):
run_channel_turn(channel=self.channel, message="hi")
def test_channel_test_chat_endpoint(self) -> None:
def test_agent_card_test_chat_endpoint(self) -> None:
client = APIClient()
client.login(username="owner@edevs.tech", password="temporary-password")
response = client.post(
f"/api/v1/channels/{self.channel.id}/test-chat/",
f"/api/v1/agents/{self.channel.id}/test-chat/",
data=json.dumps({"message": "refund"}),
content_type="application/json",
)
+2 -18
View File
@@ -1,25 +1,9 @@
from django.urls import path
from hub_platform.ai import agent_views, bulk_views, category_views, views
from hub_platform.ai import bulk_views, category_views, views
urlpatterns = [
path("agents/", agent_views.AIAgentListView.as_view(), name="ai-agent-list"),
path("agents/<int:agent_id>/", agent_views.AIAgentDetailView.as_view(), name="ai-agent-detail"),
path(
"agents/<int:agent_id>/update/",
agent_views.AIAgentUpdateView.as_view(),
name="ai-agent-update",
),
path(
"agents/<int:agent_id>/activate/",
agent_views.AIAgentActivateView.as_view(),
name="ai-agent-activate",
),
path(
"agents/<int:agent_id>/deactivate/",
agent_views.AIAgentDeactivateView.as_view(),
name="ai-agent-deactivate",
),
# agentId — id AIAgent, как и в knowledge/bulk/agent/ (CRUD агентов — /agents/).
path(
"agents/<int:agent_id>/knowledge/select-category/",
bulk_views.AgentCategoryKnowledgeSelectView.as_view(),
@@ -1,98 +0,0 @@
"""Разбор тел запросов API каналов (SPEC-HUB-0027 §6.3, §6.5).
Отделено от views, чтобы представления оставались тонкими: проверка формы —
здесь, доменные правила — в services, авторизация — в authorization.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from hub_platform.channels.policy import (
POLICY_API_FIELDS,
ChannelPolicy,
PolicyPreset,
)
from hub_platform.channels.services import UNSET, ChannelUpdate
def _optional_id(data: dict, key: str, current=UNSET):
if key not in data:
return current
value = data[key]
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValidationError({key: f"{key} must be an integer or null"})
return value
def _optional_bool(data: dict, key: str):
if key not in data:
return UNSET
value = data[key]
if not isinstance(value, bool):
raise ValidationError({key: f"{key} must be a boolean"})
return value
def parse_policy_fields(raw: object) -> dict[str, bool]:
"""camelCase-ключи политики -> поля модели. Неизвестные ключи отвергаются."""
if not isinstance(raw, dict):
raise ValidationError({"policy": "policy must be an object"})
unknown = set(raw) - set(POLICY_API_FIELDS)
if unknown:
raise ValidationError({"policy": f"Неизвестные флаги: {', '.join(sorted(unknown))}"})
fields: dict[str, bool] = {}
for key, value in raw.items():
if not isinstance(value, bool):
raise ValidationError({"policy": f"{key} must be a boolean"})
fields[POLICY_API_FIELDS[key]] = value
return fields
def parse_create_policy(data: dict) -> ChannelPolicy:
"""§6.3: policyPreset и policy взаимоисключающи, CUSTOM требует policy."""
preset = data.get("policyPreset")
has_policy = "policy" in data
if preset is None and not has_policy:
raise ValidationError({"policy": "Укажите policyPreset или policy"})
if preset in {PolicyPreset.SALES, PolicyPreset.SUPPORT}:
if has_policy:
raise ValidationError(
{"policy": "policyPreset и policy взаимоисключающи"}
)
return ChannelPolicy.from_preset(preset)
if preset == PolicyPreset.CUSTOM and not has_policy:
raise ValidationError({"policy": "Пресет CUSTOM требует policy"})
if preset is not None and preset != PolicyPreset.CUSTOM:
raise ValidationError({"policyPreset": f"Неизвестный пресет: {preset}"})
fields = parse_policy_fields(data.get("policy"))
missing = set(POLICY_API_FIELDS.values()) - set(fields)
if missing:
raise ValidationError({"policy": "Политика задаётся всеми пятью флагами"})
return ChannelPolicy(**fields)
def parse_connection_ids(raw: object) -> list[int]:
if raw is None:
return []
if not isinstance(raw, list) or any(
isinstance(item, bool) or not isinstance(item, int) for item in raw
):
raise ValidationError({"connectionIds": "connectionIds must be a list of integers"})
return list(dict.fromkeys(raw))
def parse_update(data: dict, *, current_code: str) -> ChannelUpdate:
"""§6.5: code в теле игнорируется, другое значение — 400."""
if "code" in data and str(data["code"] or "").strip() != current_code:
raise ValidationError({"code": "Код канала не изменяется после создания"})
return ChannelUpdate(
name=data["name"] if "name" in data else UNSET,
group_id=_optional_id(data, "groupId"),
product_id=_optional_id(data, "productId"),
is_active=_optional_bool(data, "isActive"),
policy=parse_policy_fields(data["policy"]) if "policy" in data else {},
)
@@ -54,45 +54,3 @@ def channel_for_context(
*, context: TenantContext, channel_id: int, capability: str = CHANNELS_VIEW
) -> Channel:
return channels_for_context(context, capability=capability).get(id=channel_id)
def _parse_reference(raw: str) -> tuple[str, int | None]:
"""`none` — явный фильтр «без связи», иначе идентификатор."""
if raw == "none":
return "none", None
try:
return "id", int(raw)
except (TypeError, ValueError):
return "invalid", None
def filter_channels(queryset: QuerySet[Channel], params) -> QuerySet[Channel]:
"""Фильтры §6.2. Scope уже применён селектором и здесь не расширяется."""
group = params.get("group")
if group:
kind, value = _parse_reference(group)
if kind == "none":
queryset = queryset.filter(group__isnull=True)
elif kind == "id":
queryset = queryset.filter(group_id=value)
product = params.get("product")
if product:
kind, value = _parse_reference(product)
if kind == "none":
queryset = queryset.filter(product__isnull=True)
elif kind == "id":
queryset = queryset.filter(product_id=value)
is_active = params.get("isActive")
if is_active in {"true", "false"}:
queryset = queryset.filter(is_active=is_active == "true")
has_agent = params.get("hasAgent")
if has_agent in {"true", "false"}:
queryset = queryset.filter(ai_agent__isnull=has_agent == "false")
search = (params.get("q") or "").strip()
if search:
queryset = queryset.filter(Q(name__icontains=search) | Q(code__icontains=search))
return queryset
@@ -1,77 +0,0 @@
from hub_platform.channels.models import Channel
from hub_platform.channels.policy import ChannelPolicy
def _agent_payload(channel: Channel) -> dict[str, object] | None:
"""Сводка для ссылки на существующую страницу агента (SPEC-HUB-0027 §6.1).
Конфигурация агента (persona, tone, instructions, allowedTools, limits,
knowledgeIds) в payload канала не входит и через API канала не изменяется.
Знаний и их счётчика здесь нет: знания принадлежат агенту.
"""
agent = getattr(channel, "ai_agent", None)
if agent is None:
return None
return {
"id": agent.id,
"name": agent.name,
"status": agent.status,
"model": agent.model,
}
def _connections_payload(channel: Channel) -> list[dict[str, object]]:
return [
{
"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 _open_conversations(channel: Channel) -> int:
# Селекторы аннотируют счётчик одним агрегатом на список (SPEC §6.1):
# запрос ниже — страховка для объектов, собранных в обход селектора.
count = getattr(channel, "open_conversations_count", None)
if count is None:
from hub_platform.conversations.models import LifecycleState
return channel.conversations.filter(lifecycle=LifecycleState.OPEN).count()
return count
def channel_payload(channel: Channel) -> dict[str, object]:
policy = ChannelPolicy.from_channel(channel)
connections = _connections_payload(channel)
agent = _agent_payload(channel)
return {
"id": channel.id,
"code": channel.code,
"name": channel.name,
"isActive": channel.is_active,
"product": {
"id": channel.product.id,
"code": channel.product.code,
"name": channel.product.name,
}
if channel.product_id
else None,
"groupId": channel.group_id,
"groupName": channel.group.name if channel.group_id else None,
"agent": agent,
"connections": connections,
"policy": policy.as_payload(),
"counters": {
"openConversations": _open_conversations(channel),
"connections": len(connections),
},
"createdAt": channel.created_at.isoformat(),
"updatedAt": channel.updated_at.isoformat(),
# Совместимость на один релиз: плоские ключи политики и agentId живут
# рядом с policy/agent и удаляются после перевода клиентов (SPEC §6.1).
"agentId": agent["id"] if agent else None,
**policy.as_payload(),
}
@@ -2,7 +2,6 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
@@ -18,7 +17,6 @@ from hub_platform.products.models import Product
from hub_platform.tenancy.context import TenantContext
# SPEC §3.1: slug 1-64, входит в embed-URL web-виджета и после создания immutable.
CODE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$")
CODE_MAX_LENGTH = 64
NAME_MAX_LENGTH = 255
@@ -33,12 +31,6 @@ class Unset:
UNSET = Unset()
class ChannelCodeConflict(Exception):
def __init__(self, code: str) -> None:
self.code = code
super().__init__(f"Канал с кодом {code} уже существует")
class ChannelHasReferences(Exception):
"""§7.2. SET_NULL и CASCADE не считаются разрешением на удаление."""
@@ -81,15 +73,6 @@ class ChannelUpdate:
policy: dict[str, bool] = field(default_factory=dict)
def _clean_code(raw: object) -> str:
code = str(raw or "").strip()
if not code or len(code) > CODE_MAX_LENGTH or not CODE_PATTERN.match(code):
raise ValidationError(
{"code": "Код канала: 1-64 символа, латиница в нижнем регистре, цифры и дефис"}
)
return code
def _clean_name(raw: object) -> str:
name = str(raw or "").strip()
if not name:
@@ -126,43 +109,6 @@ def _product_for_channel(
raise ValidationError({"productId": "Unknown product"}) from error
@transaction.atomic
def create_channel(
*,
context: TenantContext,
code: object,
name: object,
group_id: int | None,
product_id: int | None,
policy: ChannelPolicy,
connection_ids: list[int] | None = None,
) -> Channel:
authorization.require_organization_manage(context, operation="Создание канала")
clean_code = _clean_code(code)
clean_name = _clean_name(name)
group = _group_for_channel(context=context, group_id=group_id)
product = _product_for_channel(context=context, product_id=product_id)
# Инварианты проверяются до записи: частичное применение запрещено (§3.2).
require_valid_policy(policy=policy, has_product=product is not None)
if Channel.objects.filter(
organization_id=context.organization_id, code=clean_code
).exists():
raise ChannelCodeConflict(clean_code)
channel = Channel.objects.create(
organization_id=context.organization_id,
code=clean_code,
name=clean_name,
group=group,
product=product,
**policy.as_model_fields(),
)
for integration_id in connection_ids or []:
bind_connection(context=context, channel=channel, integration_id=integration_id)
return channel
@transaction.atomic
def update_channel(
*, context: TenantContext, channel: Channel, update: ChannelUpdate
@@ -228,28 +174,6 @@ def update_channel(
return locked
def deletion_blockers(channel: Channel) -> list[dict[str, Any]]:
"""§7.2. Каскадное удаление агента запрещено, поэтому SET_NULL и CASCADE
тоже блокируют."""
counts = (
("conversations", channel.conversations.count()),
("connections", channel.connections.count()),
("supportContracts", channel.allowed_support_contracts.count()),
("agent", 1 if hasattr(channel, "ai_agent") else 0),
("llmInvocations", channel.ai_invocations.count()),
)
return [{"type": name, "count": count} for name, count in counts if count]
@transaction.atomic
def delete_channel(*, context: TenantContext, channel: Channel) -> None:
authorization.require_organization_manage(context, operation="Удаление канала")
blockers = deletion_blockers(channel)
if blockers:
raise ChannelHasReferences(blockers)
channel.delete()
def _messenger_integration(*, context: TenantContext, integration_id: int) -> Integration:
try:
integration = Integration.objects.select_related("channel").get(
-569
View File
@@ -1,569 +0,0 @@
import json
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.group_models import EmployeeGroup
from hub_platform.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
from hub_platform.products.models import Product
SALES_POLICY = {
"requiresAuthenticatedProductIdentity": False,
"allowAnonymousSessions": True,
"allowSelfReportedContact": True,
"allowSalesAttribution": True,
"allowCheckoutActions": True,
}
OPERATOR_POLICY = {
"requiresAuthenticatedProductIdentity": False,
"allowAnonymousSessions": True,
"allowSelfReportedContact": True,
"allowSalesAttribution": False,
"allowCheckoutActions": False,
}
def _make_channel(organization, *, code, name, **extra):
extra.setdefault("allow_sales_attribution", False)
extra.setdefault("allow_checkout_actions", False)
return Channel.objects.create(organization=organization, code=code, name=name, **extra)
def _messenger(organization, *, name="Bot", channel=None):
return Integration.objects.create(
organization=organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name=name,
channel=channel,
)
class ChannelApiTestCase(TestCase):
"""Общий владелец и клиент: owner имеет все capability по роли."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.operators = self.organization.employee_groups.get(name="Операторы")
self.support_group = self.organization.employee_groups.get(name="Поддержка")
self.product = Product.objects.get(organization=self.organization, code="foxray")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def post_channel(self, **overrides):
body = {
"code": "partners",
"name": "Партнёрская линия",
"groupId": None,
"productId": None,
"policyPreset": "CUSTOM",
"policy": OPERATOR_POLICY,
}
body.update(overrides)
return self.client.post(
"/api/v1/channels/", data=json.dumps(body), content_type="application/json"
)
def patch_channel(self, channel_id: int, **body):
return self.client.patch(
f"/api/v1/channels/{channel_id}/",
data=json.dumps(body),
content_type="application/json",
)
class ChannelCreateTests(ChannelApiTestCase):
def test_creates_operator_channel_without_agent(self) -> None:
response = self.post_channel()
self.assertEqual(response.status_code, 201)
channel = response.json()["channel"]
self.assertEqual(channel["code"], "partners")
# Канал без агента — валидное операторское состояние (ADR-HUB-0037 §5).
self.assertIsNone(channel["agent"])
self.assertEqual(channel["connections"], [])
self.assertTrue(channel["isActive"])
self.assertTrue(Channel.objects.filter(code="partners").exists())
self.assertFalse(AIAgent.objects.exists())
def test_payload_carries_no_agent_configuration_or_knowledge(self) -> None:
response = self.post_channel()
channel = response.json()["channel"]
forbidden = {
"persona", "tone", "instructions", "model", "credentialMode",
"allowedTools", "limits", "knowledgeIds", "knowledge", "knowledgeCount",
}
self.assertEqual(forbidden & set(channel), set())
def test_sales_preset_fills_policy(self) -> None:
response = self.post_channel(
code="foxray-sales",
name="FoxRay — продажи",
productId=self.product.id,
groupId=self.operators.id,
policyPreset="SALES",
policy=None,
)
# policy=None остаётся ключом в теле, поэтому пресет и явная политика
# конфликтуют — проверяем именно чистый пресет.
self.assertEqual(response.status_code, 400)
response = self.client.post(
"/api/v1/channels/",
data=json.dumps(
{
"code": "foxray-sales",
"name": "FoxRay — продажи",
"productId": self.product.id,
"groupId": self.operators.id,
"policyPreset": "SALES",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["channel"]["policy"], SALES_POLICY)
def test_rejects_duplicate_code(self) -> None:
self.post_channel()
response = self.post_channel(name="Другая линия")
self.assertEqual(response.status_code, 409)
self.assertEqual(Channel.objects.filter(code="partners").count(), 1)
def test_rejects_invalid_code(self) -> None:
for code in ("Partners", "-partners", "парт", "a" * 65, ""):
with self.subTest(code=code):
response = self.post_channel(code=code)
self.assertEqual(response.status_code, 400)
def test_policy_invariants_reject_the_whole_request(self) -> None:
# P1/P2: непродуктовый канал не формирует коммерческих действий.
response = self.post_channel(productId=None, policy=SALES_POLICY)
self.assertEqual(response.status_code, 400)
rules = {item["rule"] for item in response.json()["violations"]}
self.assertEqual(rules, {"P1", "P2"})
self.assertFalse(Channel.objects.filter(code="partners").exists())
def test_authenticated_identity_conflicts_with_anonymous_sessions(self) -> None:
response = self.post_channel(
productId=self.product.id,
policy={**SALES_POLICY, "requiresAuthenticatedProductIdentity": True},
)
self.assertEqual(response.status_code, 400)
rules = {item["rule"] for item in response.json()["violations"]}
self.assertEqual(rules, {"P4", "P5"})
def test_support_preset_requires_product(self) -> None:
response = self.client.post(
"/api/v1/channels/",
data=json.dumps(
{"code": "helpline", "name": "Поддержка", "policyPreset": "SUPPORT"}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
{item["rule"] for item in response.json()["violations"]}, {"P3"}
)
class ChannelUpdateTests(ChannelApiTestCase):
def setUp(self) -> None:
super().setUp()
self.channel = _make_channel(
self.organization, code="firepage-sales", name="FirePage — продажи"
)
def test_renames_channel_and_keeps_code(self) -> None:
response = self.patch_channel(self.channel.id, name="FirePage — продажи 2")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["channel"]["name"], "FirePage — продажи 2")
self.channel.refresh_from_db()
self.assertEqual(self.channel.code, "firepage-sales")
def test_rejects_empty_name(self) -> None:
self.assertEqual(self.patch_channel(self.channel.id, name=" ").status_code, 400)
def test_rejects_code_change(self) -> None:
response = self.patch_channel(self.channel.id, code="renamed")
self.assertEqual(response.status_code, 400)
self.channel.refresh_from_db()
self.assertEqual(self.channel.code, "firepage-sales")
def test_ignores_unchanged_code(self) -> None:
response = self.patch_channel(
self.channel.id, code="firepage-sales", name="Ещё имя"
)
self.assertEqual(response.status_code, 200)
def test_changes_product_status_and_policy(self) -> None:
response = self.patch_channel(
self.channel.id,
productId=self.product.id,
isActive=False,
policy={"allowCheckoutActions": True},
)
self.assertEqual(response.status_code, 200)
self.channel.refresh_from_db()
self.assertEqual(self.channel.product_id, self.product.id)
self.assertFalse(self.channel.is_active)
self.assertTrue(self.channel.allow_checkout_actions)
def test_deactivation_warns_about_active_agent(self) -> None:
AIAgent.objects.create(
channel=self.channel,
name="FirePage Agent",
status=AIAgentStatus.ACTIVE,
model="openai/gpt-4o-mini",
)
response = self.patch_channel(self.channel.id, isActive=False)
self.assertEqual(response.status_code, 200)
warnings = response.json()["warnings"]
self.assertEqual(warnings[0]["code"], "agent_still_active")
def test_rejects_unknown_policy_flag(self) -> None:
response = self.patch_channel(self.channel.id, policy={"allowEverything": True})
self.assertEqual(response.status_code, 400)
def test_other_organization_channel_is_not_found(self) -> None:
other = Organization.objects.create(slug="other", name="Other")
other_channel = _make_channel(other, code="other-sales", name="Other — продажи")
response = self.patch_channel(other_channel.id, name="Взлом")
self.assertEqual(response.status_code, 404)
other_channel.refresh_from_db()
self.assertEqual(other_channel.name, "Other — продажи")
class ChannelDeleteTests(ChannelApiTestCase):
def test_deletes_channel_without_references(self) -> None:
channel = _make_channel(self.organization, code="typo", name="Опечатка")
response = self.client.delete(f"/api/v1/channels/{channel.id}/")
self.assertEqual(response.status_code, 204)
self.assertFalse(Channel.objects.filter(id=channel.id).exists())
def test_blocks_deletion_with_references(self) -> None:
channel = _make_channel(self.organization, code="live", name="Живой")
_messenger(self.organization, channel=channel)
AIAgent.objects.create(
channel=channel, name="Agent", status=AIAgentStatus.DRAFT,
model="openai/gpt-4o-mini",
)
response = self.client.delete(f"/api/v1/channels/{channel.id}/")
self.assertEqual(response.status_code, 409)
blockers = {item["type"]: item["count"] for item in response.json()["blockers"]}
self.assertEqual(blockers, {"connections": 1, "agent": 1})
self.assertTrue(Channel.objects.filter(id=channel.id).exists())
class ChannelConnectionTests(ChannelApiTestCase):
def setUp(self) -> None:
super().setUp()
self.channel = _make_channel(self.organization, code="main", name="Основной")
self.integration = _messenger(self.organization, name="Telegram Bot")
def _bind(self, channel_id: int, integration_id: int, **body):
return self.client.post(
f"/api/v1/channels/{channel_id}/connections/",
data=json.dumps({"integrationId": integration_id, **body}),
content_type="application/json",
)
def test_binds_and_unbinds_connection(self) -> None:
response = self._bind(self.channel.id, self.integration.id)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["channel"]["connections"]), 1)
response = self.client.delete(
f"/api/v1/channels/{self.channel.id}/connections/{self.integration.id}/"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["channel"]["connections"], [])
self.integration.refresh_from_db()
self.assertIsNone(self.integration.channel_id)
def test_rejects_llm_provider_as_connection(self) -> None:
provider = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.LLM_PROVIDER,
provider=IntegrationProvider.OPENROUTER,
name="OpenRouter",
)
self.assertEqual(self._bind(self.channel.id, provider.id).status_code, 400)
def test_rejects_binding_to_archived_channel(self) -> None:
self.channel.is_active = False
self.channel.save(update_fields=["is_active"])
self.assertEqual(self._bind(self.channel.id, self.integration.id).status_code, 400)
def test_move_between_channels_requires_force(self) -> None:
other = _make_channel(self.organization, code="second", name="Второй")
self._bind(self.channel.id, self.integration.id)
response = self._bind(other.id, self.integration.id)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["code"], "connection_already_bound")
self.integration.refresh_from_db()
self.assertEqual(self.integration.channel_id, self.channel.id)
response = self._bind(other.id, self.integration.id, force=True)
self.assertEqual(response.status_code, 200)
self.integration.refresh_from_db()
self.assertEqual(self.integration.channel_id, other.id)
class ChannelListTests(ChannelApiTestCase):
def setUp(self) -> None:
super().setUp()
self.sales_channel = _make_channel(
self.organization, code="foxray-sales", name="FoxRay — продажи",
group=self.operators, product=self.product,
)
self.orphan = _make_channel(self.organization, code="edevs", name="Edevs — сайт")
self.archived = _make_channel(
self.organization, code="old", name="Старый", is_active=False
)
def test_lists_all_channels_for_owner(self) -> None:
response = self.client.get("/api/v1/channels/")
self.assertEqual(response.status_code, 200)
codes = {item["code"] for item in response.json()["items"]}
self.assertEqual(codes, {"foxray-sales", "edevs", "old"})
def test_filters_by_group_product_and_status(self) -> None:
cases = (
({"group": "none"}, {"edevs", "old"}),
({"group": str(self.operators.id)}, {"foxray-sales"}),
({"product": "none"}, {"edevs", "old"}),
({"isActive": "false"}, {"old"}),
({"hasAgent": "false"}, {"foxray-sales", "edevs", "old"}),
({"q": "прода"}, {"foxray-sales"}),
)
for params, expected in cases:
with self.subTest(params=params):
response = self.client.get("/api/v1/channels/", params)
codes = {item["code"] for item in response.json()["items"]}
self.assertEqual(codes, expected)
def test_counters_do_not_issue_a_query_per_channel(self) -> None:
# §6.1: счётчик открытых диалогов — один агрегат на список. Проверяем
# само свойство, а не абсолютное число: рост каналов не должен менять
# количество запросов.
def count_queries() -> int:
with CaptureQueriesContext(connection) as captured:
response = self.client.get("/api/v1/channels/")
self.assertEqual(response.status_code, 200)
return len(captured)
baseline = count_queries()
for index in range(5):
_make_channel(
self.organization, code=f"extra-{index}", name=f"Канал {index}"
)
self.assertEqual(len(self.client.get("/api/v1/channels/").json()["items"]), 8)
self.assertEqual(count_queries(), baseline)
class ChannelGroupTests(ChannelApiTestCase):
"""Канал закрепляется за настраиваемой группой (ADR-HUB-0043 §3)."""
def setUp(self) -> None:
super().setUp()
self.channel = _make_channel(
self.organization, code="foxray-sales", name="FoxRay — продажи",
group=self.operators, product=self.product,
)
def test_payload_exposes_group_reference(self) -> None:
response = self.client.get(f"/api/v1/channels/{self.channel.id}/")
channel = response.json()["channel"]
self.assertEqual(channel["groupId"], self.operators.id)
self.assertEqual(channel["groupName"], "Операторы")
def test_manager_moves_channel_between_groups_and_detaches(self) -> None:
moved = self.patch_channel(self.channel.id, groupId=self.support_group.id)
self.assertEqual(moved.status_code, 200)
self.channel.refresh_from_db()
self.assertEqual(self.channel.group_id, self.support_group.id)
detached = self.patch_channel(self.channel.id, groupId=None)
self.assertEqual(detached.status_code, 200)
self.channel.refresh_from_db()
self.assertIsNone(self.channel.group_id)
def test_foreign_organization_group_is_rejected(self) -> None:
other = Organization.objects.create(slug="other-group-org", name="Other")
foreign = EmployeeGroup.objects.create(organization=other, name="Чужая")
response = self.patch_channel(self.channel.id, groupId=foreign.id)
self.assertEqual(response.status_code, 400)
self.channel.refresh_from_db()
self.assertEqual(self.channel.group_id, self.operators.id)
class ChannelPermissionTests(ChannelApiTestCase):
def setUp(self) -> None:
super().setUp()
self.channel = _make_channel(
self.organization, code="firepage-sales", name="FirePage — продажи"
)
operator = HumanUser.objects.create_user(
email="operator@edevs.tech", password="operator-password"
)
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
self.operator_client = APIClient()
self.operator_client.login(
username="operator@edevs.tech", password="operator-password"
)
def test_operator_without_capabilities_is_denied(self) -> None:
self.assertEqual(self.operator_client.get("/api/v1/channels/").status_code, 403)
response = self.operator_client.patch(
f"/api/v1/channels/{self.channel.id}/",
data=json.dumps({"name": "Взлом"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
class ChannelCountersTests(ChannelApiTestCase):
def test_returns_connection_breakdown_for_period(self) -> None:
channel = _make_channel(self.organization, code="main", name="Основной")
_messenger(self.organization, channel=channel)
response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=7d")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["period"], "7d")
self.assertEqual(body["conversations"], {"open": 0, "total": 0})
self.assertEqual(body["connections"], {"total": 1, "ok": 0, "error": 0})
def test_rejects_unknown_period(self) -> None:
channel = _make_channel(self.organization, code="main", name="Основной")
response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=1y")
self.assertEqual(response.status_code, 400)
class ChannelPolicyInvariantTests(ChannelApiTestCase):
"""§3.2 — P1-P5 на изменении (этап 3)."""
def setUp(self) -> None:
super().setUp()
self.orphan = _make_channel(
self.organization, code="partners", name="Партнёрская линия"
)
self.product_channel = _make_channel(
self.organization,
code="foxray-sales",
name="FoxRay — продажи",
product=self.product,
allow_sales_attribution=True,
allow_checkout_actions=True,
)
def test_rejects_commercial_flag_on_non_product_channel(self) -> None:
response = self.patch_channel(
self.orphan.id, policy={"allowCheckoutActions": True}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
{item["rule"] for item in response.json()["violations"]}, {"P1"}
)
self.orphan.refresh_from_db()
self.assertFalse(self.orphan.allow_checkout_actions)
def test_rename_of_compliant_channel_is_not_blocked_by_policy(self) -> None:
# Ровно тот сценарий, ради которого этап 3 шёл после приведения данных.
response = self.patch_channel(self.orphan.id, name="Партнёры")
self.assertEqual(response.status_code, 200)
def test_detaching_product_names_the_flags_to_turn_off(self) -> None:
response = self.patch_channel(self.product_channel.id, productId=None)
self.assertEqual(response.status_code, 400)
fields = {item["field"] for item in response.json()["violations"]}
self.assertEqual(fields, {"allowCheckoutActions", "allowSalesAttribution"})
self.product_channel.refresh_from_db()
self.assertEqual(self.product_channel.product_id, self.product.id)
def test_detaching_product_with_flags_off_succeeds_in_one_request(self) -> None:
# Инварианты считаются по итоговому состоянию, а не по переданным полям.
response = self.patch_channel(
self.product_channel.id,
productId=None,
policy={"allowCheckoutActions": False, "allowSalesAttribution": False},
)
self.assertEqual(response.status_code, 200)
self.product_channel.refresh_from_db()
self.assertIsNone(self.product_channel.product_id)
self.assertFalse(self.product_channel.allow_checkout_actions)
def test_partial_application_is_impossible(self) -> None:
response = self.patch_channel(
self.orphan.id,
name="Новое имя",
policy={"allowSalesAttribution": True},
)
self.assertEqual(response.status_code, 400)
self.orphan.refresh_from_db()
# Имя не сохранилось вместе с отклонённым флагом.
self.assertEqual(self.orphan.name, "Партнёрская линия")
self.assertFalse(self.orphan.allow_sales_attribution)
def test_authenticated_identity_requires_disabling_anonymous_flags(self) -> None:
response = self.patch_channel(
self.product_channel.id,
policy={"requiresAuthenticatedProductIdentity": True},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(
{item["rule"] for item in response.json()["violations"]}, {"P4", "P5"}
)
@@ -1,24 +0,0 @@
from django.urls import path
from hub_platform.channels import views
urlpatterns = [
path("", views.ChannelListView.as_view(), name="channel-list"),
path("<int:channel_id>/", views.ChannelDetailView.as_view(), name="channel-detail"),
path(
"<int:channel_id>/connections/",
views.ChannelConnectionsView.as_view(),
name="channel-connections",
),
path(
"<int:channel_id>/connections/<int:integration_id>/",
views.ChannelConnectionDetailView.as_view(),
name="channel-connection-detail",
),
path(
"<int:channel_id>/counters/",
views.ChannelCountersView.as_view(),
name="channel-counters",
),
path("<int:channel_id>/test-chat/", views.ChannelTestChatView.as_view(), name="channel-test-chat"),
]
-313
View File
@@ -1,313 +0,0 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
from django.utils import timezone
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.models import AIAgentStatus
from hub_platform.ai.provider.base import ProviderError
from hub_platform.api.permissions import HasCapability
from hub_platform.channels import api_inputs, services
from hub_platform.channels.models import Channel
from hub_platform.channels.policy import PolicyInvariantError
from hub_platform.channels.runtime import run_channel_turn
from hub_platform.channels.selectors import (
channel_for_context,
channels_for_context,
filter_channels,
)
from hub_platform.channels.serializers import channel_payload
from hub_platform.conversations.models import Conversation, LifecycleState
from hub_platform.identity.audit import record_audit_event
from hub_platform.integrations.models import IntegrationStatus
CHANNEL_NOT_FOUND = {"detail": "Канал не найден"}
PERIODS = {"today": None, "7d": 7, "30d": 30}
def _validation_detail(error: ValidationError) -> str:
if hasattr(error, "message_dict"):
return "; ".join(
message for messages in error.message_dict.values() for message in messages
)
return "; ".join(error.messages)
def _audit(request: Request, action: str, channel: Channel, **payload) -> None:
record_audit_event(
action=action,
actor=request.user,
organization=request.tenant_context.organization,
object_type="Channel",
object_id=str(channel.id),
payload=payload or {},
request=request,
)
def _load(request: Request, channel_id: int) -> Channel:
return channel_for_context(context=request.tenant_context, channel_id=channel_id)
class ChannelListView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "channels.view", "POST": "channels.manage"}
def get(self, request: Request) -> Response:
items = filter_channels(
channels_for_context(request.tenant_context), request.query_params
)
return Response({"items": [channel_payload(channel) for channel in items]})
def post(self, request: Request) -> Response:
data = request.data if isinstance(request.data, dict) else {}
try:
policy = api_inputs.parse_create_policy(data)
connection_ids = api_inputs.parse_connection_ids(data.get("connectionIds"))
channel = services.create_channel(
context=request.tenant_context,
code=data.get("code"),
name=data.get("name"),
group_id=data.get("groupId"),
product_id=data.get("productId"),
policy=policy,
connection_ids=connection_ids,
)
except services.ChannelCodeConflict as error:
return Response({"detail": str(error)}, status=409)
except services.ConnectionAlreadyBound as error:
return Response(error.payload(), status=409)
except PolicyInvariantError as error:
return Response(error.payload(), status=400)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
_audit(request, "channels.channel_created", channel, code=channel.code)
return Response({"channel": channel_payload(_load(request, channel.id))}, status=201)
class ChannelDetailView(APIView):
permission_classes = [HasCapability]
required_capabilities = {
"GET": "channels.view",
"PATCH": "channels.manage",
"DELETE": "channels.manage",
}
def get(self, request: Request, channel_id: int) -> Response:
try:
channel = _load(request, channel_id)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
return Response({"channel": channel_payload(channel)})
def patch(self, request: Request, channel_id: int) -> Response:
try:
channel = _load(request, channel_id)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
data = request.data if isinstance(request.data, dict) else {}
before = channel_payload(channel)
try:
update = api_inputs.parse_update(data, current_code=channel.code)
channel = services.update_channel(
context=request.tenant_context, channel=channel, update=update
)
except services.ChannelHasReferences as error:
return Response(error.payload(), status=409)
except PolicyInvariantError as error:
return Response(error.payload(), status=400)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
channel = _load(request, channel.id)
after = channel_payload(channel)
diff = {
key: {"from": before[key], "to": after[key]}
for key in ("name", "groupId", "product", "isActive", "policy")
if before[key] != after[key]
}
if diff:
_audit(request, "channels.channel_updated", channel, diff=diff)
if "isActive" in diff:
_audit(
request,
"channels.channel_activation_changed",
channel,
isActive=after["isActive"],
)
body: dict[str, object] = {"channel": after}
agent = after["agent"]
# §7.4: деактивация не останавливает агента — он продолжает занимать слот.
if diff.get("isActive", {}).get("to") is False and agent:
if agent["status"] == AIAgentStatus.ACTIVE:
body["warnings"] = [
{"code": "agent_still_active", "agentId": agent["id"]}
]
return Response(body)
def delete(self, request: Request, channel_id: int) -> Response:
try:
channel = _load(request, channel_id)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
code = channel.code
try:
services.delete_channel(context=request.tenant_context, channel=channel)
except services.ChannelHasReferences as error:
return Response(error.payload(), status=409)
record_audit_event(
action="channels.channel_deleted",
actor=request.user,
organization=request.tenant_context.organization,
object_type="Channel",
object_id=str(channel_id),
payload={"code": code},
request=request,
)
return Response(status=204)
class ChannelConnectionsView(APIView):
permission_classes = [HasCapability]
required_capability = "integrations.manage"
require_organization_scope = True
def post(self, request: Request, channel_id: int) -> Response:
try:
channel = _load(request, channel_id)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
data = request.data if isinstance(request.data, dict) else {}
integration_id = data.get("integrationId")
if isinstance(integration_id, bool) or not isinstance(integration_id, int):
return Response({"detail": "integrationId must be an integer"}, status=400)
try:
integration, previous_channel_id = services.bind_connection(
context=request.tenant_context,
channel=channel,
integration_id=integration_id,
force=bool(data.get("force")),
)
except services.ConnectionAlreadyBound as error:
return Response(error.payload(), status=409)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
if previous_channel_id is not None:
_audit(
request,
"channels.connection_moved",
channel,
integrationId=integration.id,
fromChannelId=previous_channel_id,
)
else:
_audit(
request,
"channels.connection_bound",
channel,
integrationId=integration.id,
)
return Response({"channel": channel_payload(_load(request, channel.id))})
class ChannelConnectionDetailView(APIView):
permission_classes = [HasCapability]
required_capability = "integrations.manage"
require_organization_scope = True
def delete(self, request: Request, channel_id: int, integration_id: int) -> Response:
try:
channel = _load(request, channel_id)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
try:
services.unbind_connection(
context=request.tenant_context,
channel=channel,
integration_id=integration_id,
)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
_audit(
request,
"channels.connection_unbound",
channel,
integrationId=integration_id,
)
return Response({"channel": channel_payload(_load(request, channel.id))})
class ChannelCountersView(APIView):
permission_classes = [HasCapability]
required_capability = "channels.view"
def get(self, request: Request, channel_id: int) -> Response:
try:
channel = _load(request, channel_id)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
period = request.query_params.get("period", "7d")
if period not in PERIODS:
return Response({"detail": "period must be today, 7d or 30d"}, status=400)
now = timezone.now()
since = (
now.replace(hour=0, minute=0, second=0, microsecond=0)
if period == "today"
else now - timedelta(days=PERIODS[period])
)
conversations = Conversation.objects.filter(channel=channel)
connections = channel.connections.aggregate(
total=Count("id"),
ok=Count("id", filter=Q(status=IntegrationStatus.OK)),
error=Count("id", filter=Q(status=IntegrationStatus.ERROR)),
)
return Response(
{
"period": period,
"conversations": {
# Открытые считаются на сейчас, total — созданные за период.
"open": conversations.filter(lifecycle=LifecycleState.OPEN).count(),
"total": conversations.filter(created_at__gte=since).count(),
},
"connections": connections,
}
)
class ChannelTestChatView(APIView):
permission_classes = [HasCapability]
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
required_capability = "ai.manage"
require_organization_scope = True
def post(self, request: Request, channel_id: int) -> Response:
try:
channel = channel_for_context(
context=request.tenant_context,
channel_id=channel_id,
capability="ai.view",
)
except Channel.DoesNotExist:
return Response(CHANNEL_NOT_FOUND, status=404)
message = str(request.data.get("message", "")).strip()
if not message:
return Response({"detail": "Пустое сообщение"}, status=400)
history = request.data.get("history") or []
if not isinstance(history, list):
return Response({"detail": "history must be a list"}, status=400)
try:
result = run_channel_turn(channel=channel, message=message, history=history)
except ProviderError as error:
return Response({"detail": f"Ошибка провайдера: {error}"}, status=502)
return Response(
{
"reply": result.text,
"model": result.model,
"promptTokens": result.prompt_tokens,
"completionTokens": result.completion_tokens,
}
)
@@ -1,13 +1,13 @@
import type { AgentLinkOption } from "../../shared/content-library/AgentLinkDialog";
import type { AiAgent } from "./model";
import type { AgentCard } from "../agents/model";
/** Агенты для массового прикрепления материалов (библиотека знаний общая,
* ADR-HUB-0041 §8 — фильтров по отделам больше нет). */
export function agentLinkOptions(agents: AiAgent[]): AgentLinkOption[] {
* ADR-HUB-0041 §8). Bulk-эндпоинты знаний адресуют AIAgent, поэтому id —
* aiAgentId карточки. */
export function agentLinkOptions(agents: AgentCard[]): AgentLinkOption[] {
return agents.map((agent) => ({
id: agent.id,
id: agent.aiAgentId,
name: agent.name,
channelName: agent.channel.name,
groupName: agent.channel.group?.name ?? null,
groupName: agent.groupName,
}));
}
-26
View File
@@ -1,11 +1,3 @@
export type ChannelRef = {
id: number;
code: string;
name: string;
group: { id: number; name: string } | null;
product: { code: string; name: string } | null;
};
export type AgentKnowledgeRef = { id: number; title: string; isEnabled: boolean };
export type AgentPortalArticleRef = {
@@ -15,21 +7,3 @@ export type AgentPortalArticleRef = {
portal: { id: number; name: string };
publicUrl: string;
};
export type AiAgent = {
id: number;
channel: ChannelRef;
name: string;
isActive: boolean;
model: string;
// BYOK — единственный режим (ADR-HUB-0042); провайдер принадлежит агенту, а не каналу.
providerIntegrationId: number | null;
modelParams: Record<string, unknown>;
allowedTools: unknown[];
limits: Record<string, unknown>;
persona: string;
tone: string;
instructions: string;
knowledge: AgentKnowledgeRef[];
portalArticles: AgentPortalArticleRef[];
};
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../api/client";
import type { AiAgent } from "./model";
import { fetchAgents, type AgentCard } from "../agents/model";
export function useAiAgents() {
const [agents, setAgents] = useState<AiAgent[]>([]);
const [agents, setAgents] = useState<AgentCard[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -12,7 +11,7 @@ export function useAiAgents() {
setLoading(true);
setError(false);
try {
const agentsResponse = await api<{ items: AiAgent[] }>("/api/v1/ai/agents/");
const agentsResponse = await fetchAgents();
setAgents(agentsResponse.items);
} catch {
setError(true);
@@ -9,7 +9,6 @@ export type AgentLinkAction = "attach" | "detach";
export type AgentLinkOption = {
id: number;
name: string;
channelName: string;
groupName: string | null;
};
@@ -73,7 +72,7 @@ export function AgentLinkDialog({
<option value="">Выберите агента</option>
{agents.map((agent) => (
<option value={agent.id} key={agent.id}>
{agent.name} · {agent.channelName}{agent.groupName ? ` · ${agent.groupName}` : ""}
{agent.name}{agent.groupName ? ` · ${agent.groupName}` : ""}
</option>
))}
</select>