mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c9d5c638 | ||
|
|
7e0a12a2c9 | ||
|
|
fc370fafe1 | ||
|
|
df9c6ad6fd | ||
|
|
8c618691f6 | ||
|
|
f57b1ee54a | ||
|
|
64266dcc9c | ||
|
|
cce87a4973 | ||
|
|
556246865e | ||
|
|
0aff0c564b | ||
|
|
0d307c7d06 |
No files matched your search
@@ -33,7 +33,7 @@ UI / дизайн:
|
||||
- Перед тем как написать свой элемент, искать существующий в `shared/` и в соседних фичах. Второй стандарт того же элемента — это дефект, а не свобода реализации.
|
||||
|
||||
Никаких служебных записок в продукте:
|
||||
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-HUB-*, ADR-HUB-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
|
||||
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-*, ADR-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
|
||||
- Пользователю показывается следствие и способ исправить, а не внутреннее правило. «Коммерческие действия недоступны непродуктовому каналу. Назначьте продукт» — да. «Запрещено инвариантом P1» — нет.
|
||||
- Технические коды остаются в коде, комментариях, логах и API-ответах, но не в текстах интерфейса.
|
||||
- Это относится и к baseline-макетам: служебный текст, попавший в макет, не является основанием выводить его на экран. Макет реализуется, дополняется по указанию владельца, служебная лексика в реализацию не переносится.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
|
||||
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-CHATBALLS-0028 §gateway).
|
||||
# Подставляется в release bundle и монтируется в контейнер gateway.
|
||||
#
|
||||
# Свежая установка не знает своего домена: человек поднимает докер на сервере и
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY apps/backend /app/apps/backend
|
||||
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
|
||||
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
|
||||
|
||||
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
|
||||
# collectstatic в образе (ADR-CHATBALLS-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
|
||||
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
|
||||
# collectstatic не обращается к БД/Redis/S3. whitenoise раздаёт static в runtime.
|
||||
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
|
||||
|
||||
@@ -228,7 +228,7 @@ class AgentCardDeactivateView(_AgentCardStatusView):
|
||||
|
||||
class AgentCardTestChatView(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
|
||||
# Исполняет агента, а не изменяет канал: остаётся на ai.manage.
|
||||
required_capability = "ai.manage"
|
||||
|
||||
def post(self, request: Request, agent_id: int) -> Response:
|
||||
|
||||
@@ -31,18 +31,25 @@ from chatballs.ai.provider.base import (
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
|
||||
|
||||
|
||||
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
|
||||
# каждой организации свой, и отозванный ключ одной не имеет отношения к AI
|
||||
# остальных. Общий на процесс предохранитель гасил AI у всех сразу.
|
||||
_breakers: dict[tuple[int, int], CircuitBreaker] = {}
|
||||
@dataclass(slots=True)
|
||||
class _BreakerSlot:
|
||||
revision: int
|
||||
breaker: CircuitBreaker
|
||||
|
||||
|
||||
def _breaker(key: tuple[int, int]) -> CircuitBreaker:
|
||||
breaker = _breakers.get(key)
|
||||
if breaker is None:
|
||||
breaker = CircuitBreaker()
|
||||
_breakers[key] = breaker
|
||||
return breaker
|
||||
_breakers: dict[tuple[int, int], _BreakerSlot] = {}
|
||||
|
||||
|
||||
def _breaker(key: tuple[int, int], revision: int) -> CircuitBreaker:
|
||||
slot = _breakers.get(key)
|
||||
if slot is None or slot.revision != revision:
|
||||
slot = _BreakerSlot(revision=revision, breaker=CircuitBreaker())
|
||||
_breakers[key] = slot
|
||||
return slot.breaker
|
||||
|
||||
|
||||
def reset_breakers() -> None:
|
||||
@@ -51,13 +58,14 @@ def reset_breakers() -> None:
|
||||
_breakers.clear()
|
||||
|
||||
|
||||
def _breaker_key(channel) -> tuple[int, int]:
|
||||
def _breaker_identity(channel) -> tuple[tuple[int, int], int]:
|
||||
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
|
||||
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
|
||||
|
||||
if channel is None:
|
||||
return (0, 0)
|
||||
return (channel.organization_id, routing.integration_id(channel))
|
||||
return (0, 0), 0
|
||||
integration_id, revision = routing.integration_runtime_identity(channel)
|
||||
return (channel.organization_id, integration_id), revision
|
||||
|
||||
|
||||
def _elapsed_ms(started: float) -> int:
|
||||
@@ -72,6 +80,7 @@ class ChatJob:
|
||||
model: str
|
||||
messages: list[ChatMessage]
|
||||
breaker_key: tuple[int, int]
|
||||
breaker_revision: int
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
@@ -83,6 +92,7 @@ class EmbeddingJob:
|
||||
model: str
|
||||
texts: list[str]
|
||||
breaker_key: tuple[int, int]
|
||||
breaker_revision: int
|
||||
|
||||
|
||||
def _effective_model(channel, requested_model: str | None) -> str:
|
||||
@@ -109,11 +119,13 @@ def prepare_chat(
|
||||
) -> ChatJob:
|
||||
"""Шаг в транзакции: провайдер, модель и очищенный от ПДн текст запроса."""
|
||||
|
||||
breaker_key, breaker_revision = _breaker_identity(channel)
|
||||
return ChatJob(
|
||||
provider=get_provider(channel=channel, timeout=timeout),
|
||||
model=_effective_model(channel, model),
|
||||
messages=[ChatMessage(role=item.role, content=redact(item.content)) for item in messages],
|
||||
breaker_key=_breaker_key(channel),
|
||||
breaker_key=breaker_key,
|
||||
breaker_revision=breaker_revision,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -124,7 +136,7 @@ def run_chat(job: ChatJob) -> ChatResult:
|
||||
return call_with_resilience(
|
||||
lambda: job.provider.chat(messages=job.messages, model=job.model, params=job.params),
|
||||
retries=settings.CHATBALLS_AI_MAX_RETRIES,
|
||||
breaker=_breaker(job.breaker_key),
|
||||
breaker=_breaker(job.breaker_key, job.breaker_revision),
|
||||
)
|
||||
|
||||
|
||||
@@ -200,11 +212,13 @@ def prepare_embedding(
|
||||
) -> EmbeddingJob:
|
||||
"""Шаг в транзакции: провайдер эмбеддингов организации."""
|
||||
|
||||
breaker_key, breaker_revision = _breaker_identity(channel)
|
||||
return EmbeddingJob(
|
||||
provider=get_provider(channel=channel, timeout=timeout),
|
||||
model=model,
|
||||
texts=texts,
|
||||
breaker_key=_breaker_key(channel),
|
||||
breaker_key=breaker_key,
|
||||
breaker_revision=breaker_revision,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +228,7 @@ def run_embedding(job: EmbeddingJob) -> list[EmbeddingResult]:
|
||||
return call_with_resilience(
|
||||
lambda: job.provider.embed(texts=job.texts, model=job.model),
|
||||
retries=settings.CHATBALLS_AI_MAX_RETRIES,
|
||||
breaker=_breaker(job.breaker_key),
|
||||
breaker=_breaker(job.breaker_key, job.breaker_revision),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-CHATBALLS-0024 §2).
|
||||
# Историческое поле режима доступа к AI; удалено в 0016.
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -14,8 +14,8 @@ class Migration(migrations.Migration):
|
||||
model_name='aiagent',
|
||||
name='credential_mode',
|
||||
field=models.CharField(
|
||||
choices=[('CUSTOAI', 'CustoAI (Managed)'), ('BYOK', 'BYOK')],
|
||||
default='CUSTOAI',
|
||||
choices=[('BYOK', 'BYOK')],
|
||||
default='BYOK',
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPEC-HUB-0027 §9, ADR-HUB-0037 §8 — этап 5, шаги 1-2.
|
||||
# Провайдер LLM переезжает с канала на агента.
|
||||
#
|
||||
# BYOK-секрет переезжает с канала на агента. До сих пор credential_mode и model
|
||||
# жили на AIAgent, а provider_integration — на Channel: одно решение было
|
||||
|
||||
@@ -15,7 +15,7 @@ def backfill_agents(apps, schema_editor):
|
||||
channel=channel,
|
||||
name=channel.name,
|
||||
status="DRAFT",
|
||||
credential_mode="CUSTOAI",
|
||||
credential_mode="BYOK",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
|
||||
# BYOK — единственный режим; поле credential_mode больше не нужно.
|
||||
# ADR-CHATBALLS-0042 §3: BYOK — единственный режим; поле credential_mode больше не нужно.
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from pgvector.django import VectorField
|
||||
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
|
||||
# Один основной агент на канал обработки (ADR-CHATBALLS-0023).
|
||||
|
||||
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
@@ -40,8 +40,6 @@ class AIAgentStatus(models.TextChoices):
|
||||
|
||||
|
||||
|
||||
# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-CHATBALLS-0042 §3):
|
||||
|
||||
# AI работает только через провайдера организации (AIAgent.provider_integration).
|
||||
|
||||
|
||||
@@ -151,7 +149,7 @@ class KnowledgeAttachment(TenantRelationModel):
|
||||
|
||||
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-CHATBALLS-0023):
|
||||
|
||||
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
|
||||
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации установки.
|
||||
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
|
||||
@@ -193,7 +191,7 @@ class KnowledgeAttachment(TenantRelationModel):
|
||||
|
||||
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
|
||||
|
||||
# строится от публичного адреса Hub, а не от request.
|
||||
# строится от публичного адреса установки, а не от request.
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -343,7 +341,7 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
|
||||
|
||||
# BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
|
||||
# BYOK-секрет организации. Раньше жил на Channel, из-за
|
||||
|
||||
# чего credential_mode и model были на агенте, а секрет — на канале: одно
|
||||
|
||||
@@ -494,7 +492,7 @@ class LlmInvocation(TenantRelationModel):
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
|
||||
|
||||
ADR-CHATBALLS-0034 §3).
|
||||
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-CHATBALLS-0034 §3).
|
||||
|
||||
|
||||
|
||||
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
|
||||
The OpenRouter and generic Custom providers both
|
||||
|
||||
speak the same Chat Completions shape:
|
||||
|
||||
@@ -20,7 +18,7 @@ speak the same Chat Completions shape:
|
||||
|
||||
|
||||
|
||||
This module owns the HTTP transport and response parsing so the three adapters
|
||||
This module owns the HTTP transport and response parsing so the adapters
|
||||
|
||||
do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
@@ -38,17 +36,11 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from chatballs.ai.provider.base import (
|
||||
|
||||
ChatMessage,
|
||||
|
||||
ChatResult,
|
||||
|
||||
EmbeddingResult,
|
||||
|
||||
ProviderError,
|
||||
|
||||
ProviderRejected,
|
||||
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
@@ -8,7 +8,7 @@ class OpenRouterProvider(LLMProvider):
|
||||
|
||||
OpenAI Chat Completions shape with usage.include=true (returns the actual
|
||||
USD cost in usage.cost). Delegates HTTP/parsing to the shared openai_http
|
||||
layer (ADR-HUB-0033 §7, ADR-CHATBALLS-0034 §3); this adapter only carries the
|
||||
layer (ADR-CHATBALLS-0034 §3); this adapter only carries the
|
||||
OpenRouter product semantics (cost reporting). Exercised with a real key;
|
||||
tests use the LocalProvider.
|
||||
"""
|
||||
|
||||
@@ -38,10 +38,10 @@ def call_with_resilience(
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
backoff: float = 0.5,
|
||||
):
|
||||
if breaker is not None:
|
||||
breaker.before()
|
||||
attempt = 0
|
||||
while True:
|
||||
if breaker is not None:
|
||||
breaker.before()
|
||||
try:
|
||||
result = func()
|
||||
except ProviderRejected:
|
||||
@@ -49,10 +49,10 @@ def call_with_resilience(
|
||||
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
|
||||
raise
|
||||
except ProviderError:
|
||||
if breaker is not None:
|
||||
breaker.on_failure()
|
||||
attempt += 1
|
||||
if attempt > retries:
|
||||
if breaker is not None:
|
||||
breaker.on_failure()
|
||||
raise
|
||||
sleep(backoff * attempt)
|
||||
continue
|
||||
|
||||
@@ -5,7 +5,7 @@ Resolves an LLM provider and the effective model from the channel agent's
|
||||
own credentials, the integration is selected explicitly on `AIAgent`, and
|
||||
managed AI credits are not consumed.
|
||||
|
||||
Источник провайдера переехал с канала на агента (SPEC-HUB-0027 §9). Один
|
||||
Источник провайдера переехал с канала на агента. Один
|
||||
релиз резолвер падает на `Channel.provider_integration` для записей, не
|
||||
попавших в data-миграцию; после удаления поля канала fallback уходит.
|
||||
|
||||
@@ -14,7 +14,7 @@ the owner's choice is forbidden (ADR-CHATBALLS-0020:45). The integration MUST be
|
||||
the one the agent points at.
|
||||
|
||||
This module also closes the as-built gap where the OpenRouter «Модель по
|
||||
умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC-CHATBALLS-0024 §4.3, §6):
|
||||
умолчанию» field was decorative (SPEC-CHATBALLS-0024 §4.3, §6):
|
||||
for OpenRouter and Custom integrations the configured `default_model` is read
|
||||
at runtime and overrides `AIAgent.model`.
|
||||
"""
|
||||
@@ -114,7 +114,7 @@ def _channel_integration(channel) -> Integration:
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
integration = getattr(agent, "provider_integration", None) if agent else None
|
||||
if integration is None:
|
||||
# Переходный fallback на один релиз (SPEC-HUB-0027 §9 шаг 3): записи,
|
||||
# Переходный fallback на один релиз: записи,
|
||||
# не попавшие в data-миграцию, продолжают работать через канал.
|
||||
integration = getattr(channel, "provider_integration", None)
|
||||
if integration is None or not integration.secret:
|
||||
@@ -136,6 +136,16 @@ def integration_id(channel) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def integration_runtime_identity(channel) -> tuple[int, int]:
|
||||
"""Идентификатор и версия runtime-настроек выбранного провайдера."""
|
||||
|
||||
try:
|
||||
integration = _channel_integration(channel)
|
||||
except IntegrationNotConfigured:
|
||||
return 0, 0
|
||||
return integration.id, integration.runtime_revision
|
||||
|
||||
|
||||
def _provider_from_integration(
|
||||
integration: Integration, *, timeout: float | None = None
|
||||
) -> LLMProvider:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Выбор LLM-провайдера агента (SPEC-HUB-0027 §9, ADR-CHATBALLS-0042 §3).
|
||||
"""Выбор LLM-провайдера агента (ADR-CHATBALLS-0042 §3).
|
||||
|
||||
Managed-режим CustoAI удалён вместе с тарифным контуром: агент работает только
|
||||
через интеграцию организации (BYOK, ADR-CHATBALLS-0034). Функция ничего не пишет:
|
||||
Агент работает только через интеграцию организации (BYOK,
|
||||
ADR-CHATBALLS-0034). Функция ничего не пишет:
|
||||
возвращает разрешённую интеграцию и модель, вызывающий сервис ставит их на
|
||||
агента в одной транзакции. Агент без интеграции — валидное состояние черновика;
|
||||
активация без провайдера запрещена в set_agent_active.
|
||||
|
||||
@@ -130,7 +130,7 @@ class ProviderModeTests(TestCase):
|
||||
|
||||
|
||||
class AgentProviderOwnershipTests(TestCase):
|
||||
"""SPEC-HUB-0027 §9 — провайдер живёт на агенте, канал не изменяется."""
|
||||
"""Провайдер живёт на агенте, канал не изменяется."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from chatballs.ai.invocation import _breaker, reset_breakers
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.resilience import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
call_with_resilience,
|
||||
)
|
||||
|
||||
|
||||
class ProviderResilienceTests(SimpleTestCase):
|
||||
def test_retries_count_as_one_logical_failure(self) -> None:
|
||||
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
|
||||
calls = {"n": 0}
|
||||
|
||||
def always_fail():
|
||||
calls["n"] += 1
|
||||
raise ProviderError("down")
|
||||
|
||||
for _ in range(2):
|
||||
with self.assertRaises(ProviderError):
|
||||
call_with_resilience(
|
||||
always_fail,
|
||||
retries=2,
|
||||
breaker=breaker,
|
||||
sleep=lambda _s: None,
|
||||
)
|
||||
self.assertEqual(calls["n"], 6)
|
||||
with self.assertRaises(CircuitBreakerOpen):
|
||||
call_with_resilience(always_fail, retries=2, breaker=breaker)
|
||||
self.assertEqual(calls["n"], 6)
|
||||
|
||||
def test_circuit_breaker_allows_probe_after_cooldown(self) -> None:
|
||||
now = [0.0]
|
||||
breaker = CircuitBreaker(
|
||||
failure_threshold=1,
|
||||
reset_timeout=30,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
|
||||
def fail():
|
||||
raise ProviderError("down")
|
||||
|
||||
with self.assertRaises(ProviderError):
|
||||
call_with_resilience(fail, retries=0, breaker=breaker)
|
||||
now[0] = 29
|
||||
with self.assertRaises(CircuitBreakerOpen):
|
||||
call_with_resilience(lambda: "ok", retries=0, breaker=breaker)
|
||||
now[0] = 30
|
||||
self.assertEqual(
|
||||
call_with_resilience(lambda: "ok", retries=0, breaker=breaker),
|
||||
"ok",
|
||||
)
|
||||
|
||||
def test_runtime_revision_replaces_open_breaker(self) -> None:
|
||||
reset_breakers()
|
||||
self.addCleanup(reset_breakers)
|
||||
first = _breaker((1, 2), revision=1)
|
||||
for _ in range(first.failure_threshold):
|
||||
first.on_failure()
|
||||
with self.assertRaises(CircuitBreakerOpen):
|
||||
first.before()
|
||||
|
||||
second = _breaker((1, 2), revision=2)
|
||||
self.assertIsNot(second, first)
|
||||
second.before()
|
||||
@@ -16,7 +16,7 @@ _MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
|
||||
|
||||
|
||||
def make_channel_with_agent(organization, *, code, name, model="openai/gpt-4o-mini"):
|
||||
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт
|
||||
"""Канал обработки + его агент (ADR-CHATBALLS-0023). Bootstrap не создаёт
|
||||
каналы/агентов — в тестах их собирает этот helper."""
|
||||
channel = Channel.objects.create(organization=organization, code=code, name=name)
|
||||
agent = AIAgent.objects.create(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
|
||||
# Проверка инвариантов политики канала.
|
||||
#
|
||||
# Приводит существующие каналы в соответствие P1-P2 и меняет дефолты модели,
|
||||
# которые сами по себе их нарушали: attribution и checkout были включены по
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.db import models
|
||||
|
||||
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и
|
||||
# Канал обработки — якорь AI-контекста. Группа видимости и
|
||||
# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания)
|
||||
# живёт на агенте канала (ADR-CHATBALLS-0023).
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from chatballs.i18n import t
|
||||
|
||||
|
||||
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
|
||||
"""One AI turn for a processing channel (ADR-HUB-0019/0023).
|
||||
"""One AI turn for a processing channel (ADR-CHATBALLS-0023).
|
||||
|
||||
AI-поведение канала целиком определяет его агент. Без активного агента
|
||||
AI-ответа нет: вызывающий код (ingest/support) обрабатывает ProviderError
|
||||
|
||||
@@ -13,8 +13,7 @@ def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]:
|
||||
).prefetch_related(
|
||||
Prefetch("connections", queryset=Integration.objects.order_by("id"))
|
||||
).annotate(
|
||||
# Один агрегат на весь список: N+1 запросов на счётчик не допускается
|
||||
# (SPEC-HUB-0027 §6.1).
|
||||
# Один агрегат на весь список: N+1 запросов на счётчик не допускается.
|
||||
open_conversations_count=Count(
|
||||
"conversations",
|
||||
filter=Q(conversations__lifecycle=LifecycleState.OPEN),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Операции над каналом обработки (SPEC-HUB-0027 §6, §7)."""
|
||||
"""Операции над каналом обработки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -42,7 +42,7 @@ class ChannelHasReferences(Exception):
|
||||
|
||||
|
||||
class ConnectionAlreadyBound(Exception):
|
||||
"""Подключение принадлежит ровно одному каналу (ADR-HUB-0019)."""
|
||||
"""Подключение принадлежит ровно одному каналу."""
|
||||
|
||||
def __init__(self, *, integration: Integration) -> None:
|
||||
self.integration = integration
|
||||
|
||||
@@ -9,5 +9,7 @@ class ConversationsConfig(AppConfig):
|
||||
|
||||
def ready(self) -> None:
|
||||
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
|
||||
from chatballs.conversations import signals # noqa: F401
|
||||
from chatballs.conversations import event_handlers # noqa: F401 (register outbox handlers)
|
||||
from chatballs.conversations import (
|
||||
event_handlers, # noqa: F401 (register outbox handlers)
|
||||
signals, # noqa: F401
|
||||
)
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from rest_framework.request import Request
|
||||
@@ -38,6 +40,26 @@ def _label_payload(label: ConversationLabel) -> dict[str, object]:
|
||||
return {"id": label.id, "name": label.name, "color": label.color}
|
||||
|
||||
|
||||
# Переменные шаблонов ответов: подставляет их интерфейс оператора при вставке
|
||||
# шаблона (internal-ui, conversations/templateVariables.ts — тот же список).
|
||||
TEMPLATE_VARIABLES = frozenset({"client_name", "operator_name", "company"})
|
||||
_TEMPLATE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
|
||||
|
||||
def _template_text_error(text: str) -> Response | None:
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
unknown = sorted(
|
||||
{match.group(0) for match in _TEMPLATE_TOKEN.finditer(text) if match.group(1) not in TEMPLATE_VARIABLES}
|
||||
)
|
||||
if unknown:
|
||||
return Response(
|
||||
{"detail": t("conversations.template_unknown_variables", names=", ".join(unknown))},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
|
||||
return {
|
||||
"id": template.id,
|
||||
@@ -443,8 +465,8 @@ class ReplyTemplateListView(APIView):
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if error := _template_text_error(text):
|
||||
return error
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, title__iexact=title
|
||||
).exists():
|
||||
@@ -475,11 +497,15 @@ class ReplyTemplateDetailView(APIView):
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=template.organization_id, title__iexact=title
|
||||
).exclude(id=template.id).exists():
|
||||
return Response({"detail": t("conversations.template_name_taken")}, status=409)
|
||||
template.title = title
|
||||
if "text" in request.data:
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if error := _template_text_error(text):
|
||||
return error
|
||||
template.text = text
|
||||
template.save()
|
||||
return Response({"template": _template_payload(template)})
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
|
||||
# Минимальный домен диалогов (ADR-CHATBALLS-0002/0003/0006). Состояние диалога
|
||||
# разделено на независимые оси; перехват оператором — атомарный.
|
||||
|
||||
|
||||
|
||||
@@ -157,15 +157,28 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _contact_username(conversation: Conversation) -> str:
|
||||
# Username живёт на identity подключения (у контакта их может быть несколько).
|
||||
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
|
||||
def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None:
|
||||
# Username и подпись гостя живут на identity подключения (у контакта их
|
||||
# может быть несколько). Только в detail-режиме — в списках это лишний
|
||||
# запрос на каждый диалог.
|
||||
if not conversation.connection_id:
|
||||
return ""
|
||||
identity = ConnectionIdentity.objects.filter(
|
||||
return None
|
||||
return ConnectionIdentity.objects.filter(
|
||||
connection_id=conversation.connection_id, contact_id=conversation.contact_id
|
||||
).first()
|
||||
return identity.username if identity else ""
|
||||
|
||||
|
||||
def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
|
||||
# Гость виджета получает имя «Гость · <код сессии>» на языке организации;
|
||||
# та же подпись записана в display_name его identity. Пока имя не сменили,
|
||||
# настоящего имени у контакта нет — подставлять его в ответ нельзя.
|
||||
if not contact.name:
|
||||
return True
|
||||
return bool(
|
||||
identity
|
||||
and identity.display_name == contact.name
|
||||
and identity.external_user_id[:6] in contact.name
|
||||
)
|
||||
|
||||
|
||||
def _contact_email(conversation: Conversation) -> str:
|
||||
@@ -206,6 +219,7 @@ def conversation_payload(
|
||||
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
|
||||
last = None if detailed else (last_message or _last_message(conversation))
|
||||
channel = conversation.channel
|
||||
identity = _connection_identity(conversation) if detailed and conversation.contact_id else None
|
||||
payload = {
|
||||
"id": conversation.id,
|
||||
"channel": {
|
||||
@@ -236,7 +250,9 @@ def conversation_payload(
|
||||
"company": conversation.contact.company,
|
||||
"city": conversation.contact.city,
|
||||
"email": _contact_email(conversation),
|
||||
"username": _contact_username(conversation) if detailed else "",
|
||||
"username": identity.username if identity else "",
|
||||
# Для переменной {{client_name}} шаблонов ответов.
|
||||
**({"isGuest": _contact_is_guest(conversation.contact, identity)} if detailed else {}),
|
||||
}
|
||||
if conversation.contact_id
|
||||
else None
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
@@ -13,6 +14,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
from chatballs.conversations.serializers import _contact_is_guest
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -339,6 +341,65 @@ class ReplyTemplateTests(ChatExtrasTestCase):
|
||||
self.assertEqual(deleted.status_code, 204)
|
||||
self.assertFalse(ReplyTemplate.objects.exists())
|
||||
|
||||
def test_rename_to_taken_title_conflicts(self) -> None:
|
||||
ReplyTemplate.objects.create(
|
||||
organization_id=self.organization.id, title="Приветствие", text="Здравствуйте!"
|
||||
)
|
||||
other = ReplyTemplate.objects.create(
|
||||
organization_id=self.organization.id, title="Прощание", text="До свидания!"
|
||||
)
|
||||
|
||||
taken = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{other.id}/",
|
||||
data=json.dumps({"title": "приветствие"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(taken.status_code, 409)
|
||||
other.refresh_from_db()
|
||||
self.assertEqual(other.title, "Прощание")
|
||||
|
||||
# Смена регистра собственного названия — не конфликт.
|
||||
recased = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{other.id}/",
|
||||
data=json.dumps({"title": "ПРОЩАНИЕ"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(recased.status_code, 200)
|
||||
self.assertEqual(recased.json()["template"]["title"], "ПРОЩАНИЕ")
|
||||
|
||||
def test_template_variables_are_checked(self) -> None:
|
||||
created = self.admin_client.post(
|
||||
"/api/v1/conversations/templates/",
|
||||
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте, {{client_name}}! Я {{ operator_name }}, {дата}."}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
|
||||
typo = self.admin_client.post(
|
||||
"/api/v1/conversations/templates/",
|
||||
data=json.dumps({"title": "Опечатка", "text": "Здравствуйте, {{clent_name}}!"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(typo.status_code, 400)
|
||||
self.assertIn("{{clent_name}}", typo.json()["detail"])
|
||||
|
||||
patched = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{created.json()['template']['id']}/",
|
||||
data=json.dumps({"text": "{{order_id}}"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(patched.status_code, 400)
|
||||
|
||||
|
||||
class ContactGuestTests(SimpleTestCase):
|
||||
def test_widget_guest_label_is_not_a_name(self) -> None:
|
||||
identity = ConnectionIdentity(external_user_id="3ffa7b0c9d", display_name="Гость · 3ffa7b")
|
||||
self.assertTrue(_contact_is_guest(Contact(name="Гость · 3ffa7b"), identity))
|
||||
self.assertTrue(_contact_is_guest(Contact(name=""), None))
|
||||
# Гость представился — имя уже настоящее.
|
||||
self.assertFalse(_contact_is_guest(Contact(name="Дмитрий Орлов"), identity))
|
||||
self.assertFalse(_contact_is_guest(Contact(name="Иван"), None))
|
||||
|
||||
|
||||
class OnboardingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -19,7 +19,6 @@ from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
|
||||
from chatballs.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
|
||||
@@ -22,7 +22,8 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import run_pending_ai_turns
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
|
||||
@@ -267,10 +267,10 @@ class VkOutboundTests(SimpleTestCase):
|
||||
with mock.patch.object(vk_send.vk_api, "call", api):
|
||||
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
|
||||
sent = vk_send.send_call_invite(
|
||||
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
|
||||
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://chatballs.test/calls/abc"
|
||||
)
|
||||
self.assertTrue(sent)
|
||||
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
|
||||
self.assertIn("https://chatballs.test/calls/abc", api.params("messages.send")["keyboard"])
|
||||
|
||||
def test_rejected_send_is_reported_as_failure(self) -> None:
|
||||
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.test import TestCase
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import ai_turn
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import ControlMode, MessageAuthor, MessageKind, TranscriptStatus
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
@@ -16,8 +17,8 @@ from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.conversations import ai_turn
|
||||
from chatballs.testing import TenantAPIClient as APIClient, ai_answer, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
|
||||
class VoiceAiReplyTests(TestCase):
|
||||
|
||||
@@ -27,8 +27,8 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
|
||||
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
|
||||
|
||||
|
||||
def _messenger_connection(channel):
|
||||
|
||||
@@ -119,7 +119,7 @@ def multipart_body(
|
||||
"""Ручной multipart/form-data (в репо urllib, без requests)."""
|
||||
import uuid
|
||||
|
||||
boundary = "----hub" + uuid.uuid4().hex
|
||||
boundary = "----chatballs" + uuid.uuid4().hex
|
||||
crlf = "\r\n"
|
||||
parts: list[bytes] = []
|
||||
for name, value in fields.items():
|
||||
|
||||
@@ -110,7 +110,7 @@ class Command(BaseCommand):
|
||||
role = str(options["role"])
|
||||
does_events = role in (ROLE_ALL, ROLE_EVENTS)
|
||||
does_polling = role in (ROLE_ALL, ROLE_POLLER)
|
||||
self.stdout.write(f"Hub worker started (role={role})")
|
||||
self.stdout.write(f"Chatballs worker started (role={role})")
|
||||
last_poll = 0.0
|
||||
last_maintenance = 0.0
|
||||
last_call_sweep = 0.0
|
||||
|
||||
@@ -12,7 +12,6 @@ from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
# Где живёт outbox. Захват идёт по всем организациям сразу, поэтому читает и
|
||||
# отмечает события роль platform, а не app (chatballs.tenancy.routing).
|
||||
OUTBOX_DB = "platform"
|
||||
|
||||
@@ -76,6 +76,7 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.template_name_taken": "A template with this name already exists",
|
||||
"conversations.template_not_found": "Template not found",
|
||||
"conversations.template_text_required": "The template text is required",
|
||||
"conversations.template_unknown_variables": "Unknown variables: {names}. Use the “Insert a variable” button to add variables.",
|
||||
"conversations.unknown_label": "Unknown label",
|
||||
"conversations.unknown_priority": "Unknown priority",
|
||||
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
|
||||
@@ -494,6 +495,7 @@ MESSAGES: dict[str, object] = {
|
||||
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
|
||||
"settings.email_not_a_bot": "Email cannot be a notifications bot",
|
||||
"settings.inactive_channel": "Inactive channel cannot accept connections",
|
||||
"settings.integration_in_use": "The integration is used by an agent or channel. Select another integration for them first.",
|
||||
"settings.integration_other_organization": "Integration belongs to another organization",
|
||||
"settings.mailbox_password_required": "Mailbox password is required",
|
||||
"settings.name_required": "Name required",
|
||||
|
||||
@@ -80,6 +80,7 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
|
||||
"conversations.template_not_found": "Шаблон не найден",
|
||||
"conversations.template_text_required": "Текст шаблона обязателен",
|
||||
"conversations.template_unknown_variables": "Неизвестные переменные: {names}. Вставляйте переменные кнопкой «Вставить переменную».",
|
||||
"conversations.unknown_label": "Неизвестная метка",
|
||||
"conversations.unknown_priority": "Неизвестный приоритет",
|
||||
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
|
||||
@@ -498,6 +499,7 @@ MESSAGES: dict[str, object] = {
|
||||
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
|
||||
"settings.email_not_a_bot": "Почта не может быть ботом уведомлений",
|
||||
"settings.inactive_channel": "Неактивный канал не принимает подключения",
|
||||
"settings.integration_in_use": "Интеграция используется агентом или каналом. Сначала выберите для них другую интеграцию.",
|
||||
"settings.integration_other_organization": "Интеграция принадлежит другой организации",
|
||||
"settings.mailbox_password_required": "Укажите пароль ящика",
|
||||
"settings.name_required": "Укажите название",
|
||||
|
||||
@@ -81,7 +81,7 @@ class InvitationRegisterView(APIView):
|
||||
|
||||
|
||||
class InvitationAcceptView(APIView):
|
||||
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
|
||||
"""Accept an organization invitation: OWNER or employee.
|
||||
|
||||
Authenticated endpoint: the caller must already have a HumanUser account
|
||||
(created through sign-up / password setup). The token is read from the body;
|
||||
|
||||
@@ -41,7 +41,7 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
support_group, _ = EmployeeGroup.objects.get_or_create(
|
||||
organization=organization, name="Поддержка"
|
||||
)
|
||||
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов.
|
||||
# Каналы обработки и их агенты создаются через API каналов.
|
||||
|
||||
owner, created_owner = HumanUser.objects.get_or_create(
|
||||
email=HumanUser.objects.normalize_email(email),
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"replyTemplates": [
|
||||
{
|
||||
"title": "Greeting",
|
||||
"text": "Hello! My name is {name}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
|
||||
"text": "Hello! My name is {{operator_name}}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
|
||||
},
|
||||
{
|
||||
"title": "Lead times",
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"replyTemplates": [
|
||||
{
|
||||
"title": "Приветствие",
|
||||
"text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
|
||||
"text": "Здравствуйте! Меня зовут {{operator_name}}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
|
||||
},
|
||||
{
|
||||
"title": "Сроки пошива",
|
||||
|
||||
@@ -30,7 +30,7 @@ from chatballs.tenancy.ingress import invitation_route
|
||||
# Приглашение существующего пользователя в организацию: письмо отправляет
|
||||
# воркер по этому событию (identity.event_handlers).
|
||||
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
|
||||
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
|
||||
# Приглашение владельца из платформенного провижининга:
|
||||
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
|
||||
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
|
||||
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
|
||||
@@ -240,7 +240,7 @@ def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitati
|
||||
|
||||
@transaction.atomic
|
||||
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
|
||||
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
|
||||
"""Принять приглашение: владельца или сотрудника.
|
||||
|
||||
Членство собирается из полей приглашения; для владельца организация ещё
|
||||
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
|
||||
@@ -337,7 +337,7 @@ def _already_accepted_for(
|
||||
token: str, user: HumanUser
|
||||
) -> AcceptedInvitation | None:
|
||||
"""Idempotent re-accept: if this token was already accepted by the same user,
|
||||
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
|
||||
return the existing result instead of raising."""
|
||||
invitation = _invitation_for_token(token, accepted=True)
|
||||
if invitation is None:
|
||||
return None
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M1-M2 (этап 1): additive schema + backfill.
|
||||
# Роли, должности и отделы сотрудников: additive schema + backfill.
|
||||
# Роли OWNER/ADMIN/EMPLOYEE, обязательная должность (пока nullable), основной отдел
|
||||
# и размещение владельца на уровне компании. Доступ существующих сотрудников не
|
||||
# расширяется: OPERATOR → EMPLOYEE, права сохраняются compatibility-адаптером.
|
||||
@@ -8,7 +8,7 @@ from django.db import migrations, models
|
||||
|
||||
def operator_to_employee(apps, schema_editor):
|
||||
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
|
||||
# OPERATOR больше не является системной ролью (ADR-HUB-0027).
|
||||
# OPERATOR больше не является системной ролью.
|
||||
EmployeeProfile.objects.filter(role="OPERATOR").update(role="EMPLOYEE")
|
||||
# Владелец всегда на уровне компании: снимаем основной отдел.
|
||||
EmployeeProfile.objects.filter(role="OWNER").exclude(primary_department__isnull=True).update(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
|
||||
# После явного заполнения должностей
|
||||
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
|
||||
# непустая должность гарантируется application contract (SPEC-CHATBALLS-0016 §5).
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated for C06 tenant provisioning (SPEC-HUB-0021 §6/§8).
|
||||
# Generated for tenant provisioning.
|
||||
# Adds Organization.status and backfills existing organizations to ACTIVE.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
|
||||
# Права на управление каналами.
|
||||
#
|
||||
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
|
||||
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
|
||||
|
||||
@@ -102,7 +102,7 @@ class HumanUser(AbstractUser):
|
||||
return self.email
|
||||
|
||||
class OrganizationStatus(models.TextChoices):
|
||||
# SPEC-HUB-0021 §6/§8: PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
|
||||
# PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
|
||||
ACTIVE = "ACTIVE", "Active"
|
||||
PENDING_OWNER = "PENDING_OWNER", "Pending owner"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
Запись идёт по основному соединению процесса. Роль app вправе вставить
|
||||
организацию только пока их нет (политика tenancy/0032); дальше создавать
|
||||
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
|
||||
организации может только роль platform. Так пароль
|
||||
platform-роли не нужен процессу backend-app.
|
||||
"""
|
||||
|
||||
|
||||
@@ -770,7 +770,7 @@ class ThrottlingTests(TestCase):
|
||||
|
||||
|
||||
class EmployeeModelInvariantTests(TestCase):
|
||||
"""ADR-HUB-0027 / SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
|
||||
"""SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
|
||||
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
|
||||
размещение владельца на уровне компании и ровно один владелец на организацию."""
|
||||
|
||||
@@ -823,7 +823,7 @@ class EmployeeModelInvariantTests(TestCase):
|
||||
|
||||
|
||||
class EmployeeGovernanceTests(TestCase):
|
||||
"""ADR-HUB-0027 этап 2 / SPEC-CHATBALLS-0016 §8,§12: административная иерархия
|
||||
"""SPEC-CHATBALLS-0016 §8,§12: административная иерархия
|
||||
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations import checks
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider, IntegrationStatus
|
||||
from chatballs.integrations.runtime import advance_revision_after_successful_check
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
_CHECKS = {
|
||||
IntegrationProvider.OPENROUTER: checks.check_openrouter,
|
||||
IntegrationProvider.CUSTOM: checks.check_custom,
|
||||
IntegrationProvider.DEMO: checks.check_demo,
|
||||
IntegrationProvider.MAX: checks.check_max,
|
||||
IntegrationProvider.TELEGRAM: checks.check_telegram,
|
||||
IntegrationProvider.VK: checks.check_vk,
|
||||
}
|
||||
|
||||
|
||||
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
|
||||
"""Проверить конфигурацию собственного Web-виджета без внешнего API."""
|
||||
|
||||
if integration.channel_id is None:
|
||||
return False, t("integrations.check_web_not_bound"), {}
|
||||
from chatballs.webchat.widgets import ensure_widget
|
||||
|
||||
try:
|
||||
widget = ensure_widget(integration)
|
||||
except ValidationError as error:
|
||||
return False, "; ".join(error.messages), {}
|
||||
if widget is None:
|
||||
return False, t("integrations.check_web_no_config"), {}
|
||||
if not widget.allowed_origins:
|
||||
return False, t("integrations.check_web_no_origins"), {}
|
||||
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
|
||||
|
||||
|
||||
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
ok, detail, meta = _check_web(context, integration)
|
||||
elif integration.provider == IntegrationProvider.EMAIL:
|
||||
ok, detail, meta = checks.check_email(
|
||||
secret=integration.secret,
|
||||
config=integration.config,
|
||||
)
|
||||
else:
|
||||
check = _CHECKS.get(integration.provider)
|
||||
if check is None:
|
||||
ok, detail, meta = False, t("integrations.check_unsupported"), {}
|
||||
else:
|
||||
ok, detail, meta = check(
|
||||
secret=integration.secret,
|
||||
base_url=str(integration.config.get("base_url", "")),
|
||||
proxy_url=str(integration.config.get("proxy_url", "")),
|
||||
)
|
||||
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
|
||||
integration.last_error = "" if ok else detail
|
||||
integration.last_checked_at = timezone.now()
|
||||
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
|
||||
if ok and advance_revision_after_successful_check(integration):
|
||||
# API и event-workers — разные процессы; ревизия инвалидирует их breaker.
|
||||
update_fields.append("runtime_revision")
|
||||
if ok and meta:
|
||||
config = {**integration.config}
|
||||
for key in ("bot_id", "bot_username", "bot_name"):
|
||||
if meta.get(key):
|
||||
config[key] = meta[key]
|
||||
if config != integration.config:
|
||||
integration.config = config
|
||||
update_fields.append("config")
|
||||
integration.save(update_fields=update_fields)
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
from chatballs.webchat.widgets import sync_widget_check_status
|
||||
|
||||
sync_widget_check_status(integration, ok=ok)
|
||||
return integration
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.deletion import ProtectedError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import Integration
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
class IntegrationInUse(Exception):
|
||||
"""Интеграция связана с агентом или каналом и не может быть удалена."""
|
||||
|
||||
|
||||
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
try:
|
||||
integration.delete()
|
||||
except ProtectedError as error:
|
||||
raise IntegrationInUse(t("settings.integration_in_use")) from error
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("integrations", "0009_integration_vk"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="integration",
|
||||
name="runtime_revision",
|
||||
field=models.PositiveBigIntegerField(default=1),
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,7 @@ from django.db import models
|
||||
from chatballs.identity.crypto import EncryptedCharField
|
||||
|
||||
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-CHATBALLS-0020.
|
||||
# Привязка подключения к каналу обработки появляется в M1 (ADR-HUB-0019).
|
||||
# Привязка подключения к каналу обработки появляется в M1.
|
||||
|
||||
|
||||
class IntegrationKind(models.TextChoices):
|
||||
@@ -63,12 +63,15 @@ class Integration(models.Model):
|
||||
voice_messages_enabled = models.BooleanField(default=True)
|
||||
audio_calls_enabled = models.BooleanField(default=True)
|
||||
video_calls_enabled = models.BooleanField(default=True)
|
||||
# Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2).
|
||||
# Подключение (бот/виджет) привязано к каналу обработки.
|
||||
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections")
|
||||
# Курсор Long Polling (marker MAX / offset Telegram).
|
||||
poll_marker = models.CharField(max_length=64, blank=True)
|
||||
last_checked_at = models.DateTimeField(null=True, blank=True)
|
||||
last_error = models.TextField(blank=True)
|
||||
# Версия runtime-настроек LLM. Event-workers держат circuit breaker в своей
|
||||
# памяти и заменяют его после исправления конфигурации провайдера.
|
||||
runtime_revision = models.PositiveBigIntegerField(default=1)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
|
||||
|
||||
def advance_revision_after_configuration_change(
|
||||
integration: Integration,
|
||||
*,
|
||||
previous_config: dict,
|
||||
previous_secret: str,
|
||||
) -> None:
|
||||
if integration.kind == IntegrationKind.LLM_PROVIDER and (
|
||||
integration.config != previous_config or integration.secret != previous_secret
|
||||
):
|
||||
integration.runtime_revision += 1
|
||||
|
||||
|
||||
def advance_revision_after_successful_check(integration: Integration) -> bool:
|
||||
if integration.kind != IntegrationKind.LLM_PROVIDER:
|
||||
return False
|
||||
integration.runtime_revision += 1
|
||||
return True
|
||||
@@ -2,11 +2,10 @@ from dataclasses import dataclass, field
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations import checks
|
||||
from chatballs.integrations.checking import test_integration as test_integration
|
||||
from chatballs.integrations.models import (
|
||||
PROVIDER_KIND,
|
||||
Integration,
|
||||
@@ -19,6 +18,9 @@ from chatballs.integrations.outbound import (
|
||||
OutboundUrlRejected,
|
||||
clean_config_url,
|
||||
)
|
||||
from chatballs.integrations.runtime import (
|
||||
advance_revision_after_configuration_change,
|
||||
)
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -28,7 +30,7 @@ class IntegrationInput:
|
||||
name: str
|
||||
secret: str | None = None # None = не менять при update
|
||||
config: dict = field(default_factory=dict)
|
||||
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
|
||||
channel_id: int | None = None # канал обработки для подключения
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@@ -118,7 +120,7 @@ def _normalized_config(provider: str, config: dict) -> dict:
|
||||
except OutboundUrlRejected as error:
|
||||
raise ValidationError({"config": t("settings.proxy_url_rejected", error=error)}) from error
|
||||
# LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом.
|
||||
# Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно
|
||||
# Для OpenRouter поле исторически декоративно; для Custom оно
|
||||
# читается в рантайме (ADR-CHATBALLS-0034 §4). Версионирование модели — дорожка ADR-0034.
|
||||
if provider in (IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM, IntegrationProvider.DEMO):
|
||||
default_model = str(config.get("defaultModel", config.get("default_model", ""))).strip()
|
||||
@@ -195,15 +197,17 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int
|
||||
_publish_web_widget(context=context, integration=integration)
|
||||
return integration
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_integration(
|
||||
*, context: TenantContext, integration: Integration, data: IntegrationInput
|
||||
) -> Integration:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
previous_config = integration.config
|
||||
previous_secret = integration.secret
|
||||
normalized_config = _normalized_config(integration.provider, data.config)
|
||||
integration.name = data.name.strip() or integration.name
|
||||
integration.config = _normalized_config(integration.provider, data.config)
|
||||
integration.config = normalized_config
|
||||
integration.channel = _resolve_channel(
|
||||
integration.organization,
|
||||
data.channel_id,
|
||||
@@ -214,6 +218,11 @@ def update_integration(
|
||||
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
|
||||
if data.secret:
|
||||
integration.secret = data.secret.strip()
|
||||
advance_revision_after_configuration_change(
|
||||
integration,
|
||||
previous_config=previous_config,
|
||||
previous_secret=previous_secret,
|
||||
)
|
||||
integration.status = IntegrationStatus.UNCHECKED
|
||||
integration.last_checked_at = None
|
||||
integration.last_error = ""
|
||||
@@ -222,77 +231,3 @@ def update_integration(
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
_publish_web_widget(context=context, integration=integration)
|
||||
return integration
|
||||
|
||||
|
||||
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
integration.delete()
|
||||
|
||||
|
||||
_CHECKS = {
|
||||
IntegrationProvider.OPENROUTER: checks.check_openrouter,
|
||||
IntegrationProvider.CUSTOM: checks.check_custom,
|
||||
IntegrationProvider.DEMO: checks.check_demo,
|
||||
IntegrationProvider.MAX: checks.check_max,
|
||||
IntegrationProvider.TELEGRAM: checks.check_telegram,
|
||||
IntegrationProvider.VK: checks.check_vk,
|
||||
}
|
||||
|
||||
|
||||
def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, str, dict]:
|
||||
"""Web-виджет обслуживается нашим же backend'ом — внешнего API нет.
|
||||
Проверяем конфигурацию конкретного widget entry point."""
|
||||
if integration.channel_id is None:
|
||||
return False, t("integrations.check_web_not_bound"), {}
|
||||
from chatballs.webchat.widgets import ensure_widget
|
||||
|
||||
try:
|
||||
widget = ensure_widget(integration)
|
||||
except ValidationError as error:
|
||||
return False, "; ".join(error.messages), {}
|
||||
if widget is None:
|
||||
return False, t("integrations.check_web_no_config"), {}
|
||||
# Пустой allowed_origins в проде запрещает вообще все домены (webchat.services.
|
||||
# origin_allowed), и на сайте виджет молча показывает «Чат временно недоступен».
|
||||
# Проверка обязана падать здесь, а не оставлять зелёный статус при мёртвом чате.
|
||||
if not widget.allowed_origins:
|
||||
return False, t("integrations.check_web_no_origins"), {}
|
||||
return True, t("integrations.check_web_active", channel=integration.channel.name), {}
|
||||
|
||||
|
||||
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
|
||||
if integration.organization_id != context.organization_id:
|
||||
raise ValidationError({"integration": t("settings.integration_other_organization")})
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
ok, detail, meta = _check_web(context, integration)
|
||||
elif integration.provider == IntegrationProvider.EMAIL:
|
||||
# Email: сигнатура шире общей (нужен весь config), диспетчеризуется отдельно.
|
||||
ok, detail, meta = checks.check_email(secret=integration.secret, config=integration.config)
|
||||
else:
|
||||
check = _CHECKS.get(integration.provider)
|
||||
if check is None:
|
||||
ok, detail, meta = False, t("integrations.check_unsupported"), {}
|
||||
else:
|
||||
ok, detail, meta = check(secret=integration.secret, base_url=str(integration.config.get("base_url", "")), proxy_url=str(integration.config.get("proxy_url", "")))
|
||||
integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR
|
||||
# Диагностика сохраняется на языке того, кто нажал «Проверить»: она живёт до
|
||||
# следующей проверки, и хранить её кодом, как историю диалога, нечего.
|
||||
integration.last_error = "" if ok else detail
|
||||
integration.last_checked_at = timezone.now()
|
||||
update_fields = ["status", "last_error", "last_checked_at", "updated_at"]
|
||||
# Идентичность бота (id/username/имя) — из ответа API, авторитетный источник.
|
||||
if ok and meta:
|
||||
config = {**integration.config}
|
||||
for key in ("bot_id", "bot_username", "bot_name"):
|
||||
if meta.get(key):
|
||||
config[key] = meta[key]
|
||||
if config != integration.config:
|
||||
integration.config = config
|
||||
update_fields.append("config")
|
||||
integration.save(update_fields=update_fields)
|
||||
if integration.provider == IntegrationProvider.WEB:
|
||||
from chatballs.webchat.widgets import sync_widget_check_status
|
||||
|
||||
sync_widget_check_status(integration, ok=ok)
|
||||
return integration
|
||||
@@ -0,0 +1,119 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.ai.models import AIAgent
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import AuditEvent, Organization
|
||||
from chatballs.integrations import checking
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
from chatballs.integrations.services import (
|
||||
IntegrationInput,
|
||||
create_integration,
|
||||
test_integration,
|
||||
update_integration,
|
||||
)
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
def custom_input(*, name: str = "Мой провайдер", secret: str | None = "sk-custom", base_url: str = "https://api.example/v1") -> IntegrationInput:
|
||||
return IntegrationInput(
|
||||
provider=IntegrationProvider.CUSTOM,
|
||||
name=name,
|
||||
secret=secret,
|
||||
config={"baseUrl": base_url, "defaultModel": "model"},
|
||||
)
|
||||
|
||||
|
||||
class ProviderRuntimeRevisionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
|
||||
def test_runtime_change_advances_revision_but_rename_does_not(self) -> None:
|
||||
integration = create_integration(context=self.context, data=custom_input(secret="sk-old"))
|
||||
initial_revision = integration.runtime_revision
|
||||
|
||||
renamed = update_integration(
|
||||
context=self.context,
|
||||
integration=integration,
|
||||
data=custom_input(
|
||||
name="Новое имя",
|
||||
secret=None,
|
||||
),
|
||||
)
|
||||
self.assertEqual(renamed.runtime_revision, initial_revision)
|
||||
|
||||
updated = update_integration(
|
||||
context=self.context,
|
||||
integration=renamed,
|
||||
data=custom_input(name=renamed.name, secret="sk-new", base_url="https://new.example/v1"),
|
||||
)
|
||||
self.assertEqual(updated.runtime_revision, initial_revision + 1)
|
||||
|
||||
def test_successful_check_advances_runtime_revision(self) -> None:
|
||||
integration = create_integration(context=self.context, data=custom_input())
|
||||
initial_revision = integration.runtime_revision
|
||||
with mock.patch.dict(
|
||||
checking._CHECKS,
|
||||
{IntegrationProvider.CUSTOM: lambda **_kwargs: (True, "ok", {})},
|
||||
):
|
||||
checked = test_integration(context=self.context, integration=integration)
|
||||
|
||||
self.assertEqual(checked.runtime_revision, initial_revision + 1)
|
||||
|
||||
|
||||
class IntegrationDeletionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _url(self, integration_id: int) -> str:
|
||||
return f"/api/v1/organizations/{self.organization.public_id}/integrations/{integration_id}/"
|
||||
|
||||
def test_used_provider_returns_conflict_and_is_not_audited_as_deleted(self) -> None:
|
||||
integration = create_integration(context=self.context, data=custom_input())
|
||||
channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="protected-provider",
|
||||
name="Канал",
|
||||
)
|
||||
AIAgent.objects.create(
|
||||
channel=channel,
|
||||
name="Агент",
|
||||
provider_integration=integration,
|
||||
)
|
||||
|
||||
response = self.client.delete(self._url(integration.id))
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertIn("используется", response.json()["detail"])
|
||||
self.assertTrue(Integration.objects.filter(id=integration.id).exists())
|
||||
self.assertFalse(
|
||||
AuditEvent.objects.filter(
|
||||
action="integrations.integration_deleted",
|
||||
object_id=str(integration.id),
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_unused_provider_is_deleted_and_audited_with_original_id(self) -> None:
|
||||
integration = create_integration(
|
||||
context=self.context,
|
||||
data=custom_input(name="Свободный провайдер"),
|
||||
)
|
||||
integration_id = integration.id
|
||||
|
||||
response = self.client.delete(self._url(integration_id))
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertFalse(Integration.objects.filter(id=integration_id).exists())
|
||||
self.assertTrue(
|
||||
AuditEvent.objects.filter(
|
||||
action="integrations.integration_deleted",
|
||||
object_id=str(integration_id),
|
||||
).exists()
|
||||
)
|
||||
@@ -510,7 +510,7 @@ class OpenRouterProviderProxyTests(TestCase):
|
||||
|
||||
provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080")
|
||||
|
||||
# После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7):
|
||||
# После рефакторинга общий HTTP-слой живёт в openai_http:
|
||||
|
||||
# мокаем именно его build_opener.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from rest_framework.views import APIView
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.integrations.deletion import IntegrationInUse, delete_integration
|
||||
from chatballs.integrations.models import Integration
|
||||
from chatballs.integrations.selectors import (
|
||||
integration_for_context,
|
||||
@@ -16,7 +17,6 @@ from chatballs.integrations.serializers import integration_payload, restore_prox
|
||||
from chatballs.integrations.services import (
|
||||
IntegrationInput,
|
||||
create_integration,
|
||||
delete_integration,
|
||||
test_integration,
|
||||
update_integration,
|
||||
)
|
||||
@@ -68,13 +68,19 @@ def _validation_error(error: Exception) -> Response:
|
||||
return Response({"detail": detail}, status=400)
|
||||
|
||||
|
||||
def _audit(request: Request, action: str, integration: Integration) -> None:
|
||||
def _audit(
|
||||
request: Request,
|
||||
action: str,
|
||||
integration: Integration,
|
||||
*,
|
||||
object_id: int | None = None,
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
action=action,
|
||||
actor=request.user,
|
||||
organization=request.tenant_context.organization,
|
||||
object_type="Integration",
|
||||
object_id=str(integration.id),
|
||||
object_id=str(integration.id if object_id is None else object_id),
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -127,8 +133,17 @@ class IntegrationDetailView(APIView):
|
||||
integration = self._get(request, integration_id)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": t("settings.integration_not_found")}, status=404)
|
||||
_audit(request, "integrations.integration_deleted", integration)
|
||||
delete_integration(context=request.tenant_context, integration=integration)
|
||||
integration_id = integration.id
|
||||
try:
|
||||
delete_integration(context=request.tenant_context, integration=integration)
|
||||
except IntegrationInUse as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
_audit(
|
||||
request,
|
||||
"integrations.integration_deleted",
|
||||
integration,
|
||||
object_id=integration_id,
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def provisioning_result_payload(
|
||||
owner_state: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Response shape for POST /api/v1/organizations. Never includes secrets,
|
||||
tokens or invitation plaintext (SPEC-HUB-0021 §12/§13)."""
|
||||
tokens or invitation plaintext."""
|
||||
return {
|
||||
"organization": {
|
||||
"publicId": str(organization.public_id),
|
||||
|
||||
@@ -26,7 +26,7 @@ class ProvisioningCommand:
|
||||
tax_profile: dict[str, Any] | None = None
|
||||
|
||||
def significant_fields(self) -> dict[str, Any]:
|
||||
"""Canonical normalized values for request_hash (SPEC-HUB-0021 §11):
|
||||
"""Canonical normalized values for request_hash:
|
||||
transport metadata is excluded. Keys are sorted for a stable hash."""
|
||||
data = asdict(self)
|
||||
data["owner_email"] = data["owner_email"].strip().lower()
|
||||
@@ -53,7 +53,7 @@ class ProvisioningResult:
|
||||
|
||||
def is_replay(provisioning: OrganizationProvisioning, command: ProvisioningCommand) -> bool:
|
||||
"""COMPLETED/WAITING_FOR_OWNER with a matching request hash is an idempotent
|
||||
replay; a differing hash is a conflict (SPEC-HUB-0021 §11)."""
|
||||
replay; a differing hash is a conflict."""
|
||||
return provisioning.request_hash == command.request_hash()
|
||||
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ class ProvisioningStatus(models.TextChoices):
|
||||
|
||||
|
||||
class OrganizationProvisioning(models.Model):
|
||||
"""Platform-owned process record (SPEC-HUB-0021 §6). Idempotency key is
|
||||
"""Platform-owned process record. Idempotency key is
|
||||
globally unique; the same key + payload returns the existing result, while a
|
||||
differing payload is a conflict. failure_details_safe must never contain
|
||||
secrets, tokens or provider credentials (SPEC-HUB-0021 §6/§13)."""
|
||||
secrets, tokens or provider credentials."""
|
||||
|
||||
idempotency_key = models.CharField(max_length=160, unique=True)
|
||||
request_hash = models.CharField(max_length=128)
|
||||
|
||||
@@ -46,7 +46,7 @@ def provision_organization(
|
||||
command: ProvisioningCommand,
|
||||
operator: PlatformOperator,
|
||||
) -> ProvisioningResult:
|
||||
"""Single write boundary for tenant provisioning (SPEC-HUB-0021 §4).
|
||||
"""Single write boundary for tenant provisioning.
|
||||
|
||||
Coordinates identity, audit and outbox in one
|
||||
transaction (тариф удалён, ADR-CHATBALLS-0042 §2: организация создаётся без подписки). Tenant-owned rows are written under set_local_tenant(new_org.id)
|
||||
@@ -201,7 +201,7 @@ def _provision_pending_owner(
|
||||
command: ProvisioningCommand,
|
||||
context: TenantContext,
|
||||
) -> None:
|
||||
# No password/dummy user is created (SPEC-HUB-0021 §8.2). An OWNER invitation
|
||||
# No password/dummy user is created. An OWNER invitation
|
||||
# is issued; the organization stays PENDING_OWNER until it is accepted.
|
||||
issued = issue_invitation(
|
||||
organization=org,
|
||||
|
||||
@@ -16,7 +16,7 @@ _REQUIRED_FIELDS = (
|
||||
def parse_provisioning_body(
|
||||
body: Any, *, idempotency_key: str, source: str
|
||||
) -> tuple[ProvisioningCommand, str | None]:
|
||||
"""Map the API body (SPEC-HUB-0021 §12) to a ProvisioningCommand. Returns
|
||||
"""Map the API body to a ProvisioningCommand. Returns
|
||||
(command, error_message). Validation is intentionally explicit (no DRF
|
||||
serializers), mirroring identity/access_payloads.py."""
|
||||
if not isinstance(body, dict):
|
||||
|
||||
@@ -19,7 +19,7 @@ _IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
|
||||
|
||||
class OrganizationProvisionView(APIView):
|
||||
"""POST <platform-host>/api/v1/organizations (SPEC-HUB-0021 §12).
|
||||
"""POST <platform-host>/api/v1/organizations.
|
||||
|
||||
The first Platform API adapter: machine-to-machine provisioning via an
|
||||
opaque platform token. Does not reuse tenant HasCapability (no membership
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.models import PortalArticle
|
||||
from chatballs.support_portals.statuses import ArticleStatus
|
||||
|
||||
|
||||
def apply_article_curation(article: PortalArticle, data: dict) -> None:
|
||||
if "sortOrder" in data:
|
||||
article.sort_order = int(data["sortOrder"])
|
||||
if "relatedArticleIds" not in data:
|
||||
return
|
||||
related_ids = data["relatedArticleIds"]
|
||||
if (
|
||||
not isinstance(related_ids, list)
|
||||
or len(related_ids) > 5
|
||||
or any(type(item) is not int for item in related_ids)
|
||||
or len(set(related_ids)) != len(related_ids)
|
||||
or article.id in related_ids
|
||||
):
|
||||
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
|
||||
valid_count = PortalArticle.objects.filter(
|
||||
id__in=related_ids,
|
||||
portal=article.portal,
|
||||
locale=article.locale,
|
||||
status=ArticleStatus.PUBLISHED,
|
||||
published_revision__isnull=False,
|
||||
).count()
|
||||
if valid_count != len(related_ids):
|
||||
raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")})
|
||||
article.related_article_ids = related_ids
|
||||
@@ -9,6 +9,7 @@ from django.utils.text import slugify
|
||||
from chatballs.ai.indexing import reindex_portal_article
|
||||
from chatballs.conversations.transports.base import guess_content_type, safe_filename
|
||||
from chatballs.i18n import t
|
||||
from chatballs.support_portals.article_curation import apply_article_curation
|
||||
from chatballs.support_portals.content_markdown import normalize_file_links
|
||||
from chatballs.support_portals.models import (
|
||||
PortalArticle,
|
||||
@@ -104,6 +105,7 @@ def create_article(
|
||||
category=category,
|
||||
slug=str(data.get("slug", "")).strip().lower(),
|
||||
locale=str(data.get("locale", portal.default_locale)).strip().lower(),
|
||||
sort_order=1000,
|
||||
)
|
||||
article.full_clean()
|
||||
article.save()
|
||||
@@ -143,8 +145,11 @@ def update_article(
|
||||
)
|
||||
article.slug = str(data.get("slug", article.slug)).strip().lower()
|
||||
article.locale = str(data.get("locale", article.locale)).strip().lower()
|
||||
apply_article_curation(article, data)
|
||||
article.full_clean()
|
||||
article.save(update_fields=["category", "slug", "locale", "updated_at"])
|
||||
article.save(update_fields=[
|
||||
"category", "slug", "locale", "sort_order", "related_article_ids", "updated_at",
|
||||
])
|
||||
return article
|
||||
|
||||
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from chatballs.support_portals.models import PortalArticle, SupportPortal
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import support_portal_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
def checked_changes(portal: SupportPortal, plan: dict) -> list[PortalArticle]:
|
||||
if plan.get("portal") != portal.slug:
|
||||
raise CommandError("Portal slug differs from the curation plan")
|
||||
locale = plan.get("locale")
|
||||
order = plan.get("order")
|
||||
related = plan.get("related")
|
||||
if not isinstance(locale, str) or not isinstance(order, dict) or not isinstance(related, dict):
|
||||
raise CommandError("Invalid curation plan")
|
||||
articles = list(
|
||||
portal.articles.filter(
|
||||
locale=locale, status="PUBLISHED", published_revision__isnull=False
|
||||
).select_related("category")
|
||||
)
|
||||
by_slug = {item.slug: item for item in articles}
|
||||
ordered = [slug for slugs in order.values() for slug in slugs]
|
||||
if (
|
||||
len(ordered) != len(by_slug)
|
||||
or len(set(ordered)) != len(ordered)
|
||||
or set(ordered) != set(by_slug)
|
||||
or set(related) != set(by_slug)
|
||||
):
|
||||
raise CommandError("Plan must cover every published article exactly once")
|
||||
for category_slug, slugs in order.items():
|
||||
if not isinstance(slugs, list) or any(
|
||||
by_slug[slug].category.slug != category_slug for slug in slugs
|
||||
):
|
||||
raise CommandError(f"Wrong article category in {category_slug}")
|
||||
for slug, targets in related.items():
|
||||
if (
|
||||
not isinstance(targets, list)
|
||||
or len(targets) > 5
|
||||
or len(set(targets)) != len(targets)
|
||||
or slug in targets
|
||||
or not set(targets) <= set(by_slug)
|
||||
):
|
||||
raise CommandError(f"Invalid related articles for {slug}")
|
||||
changed = []
|
||||
for slugs in order.values():
|
||||
for position, slug in enumerate(slugs, start=1):
|
||||
article = by_slug[slug]
|
||||
target_ids = [by_slug[target].id for target in related[slug]]
|
||||
if article.sort_order != position or article.related_article_ids != target_ids:
|
||||
article.sort_order = position
|
||||
article.related_article_ids = target_ids
|
||||
changed.append(article)
|
||||
return changed
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Validate or apply an editorial order and related articles to a public portal"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--host", required=True)
|
||||
parser.add_argument("--file", required=True, help="JSON plan path or - for stdin")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
hostname = options["host"].strip().lower().rstrip(".")
|
||||
route = support_portal_route(hostname)
|
||||
organization = load_organization(route.organization_id) if route else None
|
||||
if organization is None:
|
||||
raise CommandError("Public portal host not found")
|
||||
source = sys.stdin.read() if options["file"] == "-" else Path(options["file"]).read_text(encoding="utf-8")
|
||||
try:
|
||||
plan = json.loads(source)
|
||||
except json.JSONDecodeError as error:
|
||||
raise CommandError("Invalid JSON plan") from error
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
portal = SupportPortal.objects.filter(
|
||||
id=route.resource_id,
|
||||
organization=organization,
|
||||
status="PUBLISHED",
|
||||
).first()
|
||||
if portal is None:
|
||||
raise CommandError("Published portal not found")
|
||||
changed = checked_changes(portal, plan)
|
||||
if options["apply"] and changed:
|
||||
PortalArticle.objects.bulk_update(
|
||||
changed, ["sort_order", "related_article_ids"]
|
||||
)
|
||||
action = "Updated" if options["apply"] else "Would update"
|
||||
self.stdout.write(f"{action} {len(changed)} articles")
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("support_portals", "0011_hosted_domains_follow_installation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="portalarticle",
|
||||
name="sort_order",
|
||||
field=models.PositiveIntegerField(default=1000),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="portalarticle",
|
||||
name="related_article_ids",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
@@ -153,6 +153,8 @@ class PortalArticle(TenantRelationModel):
|
||||
)
|
||||
slug = models.SlugField(max_length=96)
|
||||
locale = models.CharField(max_length=16, default="ru")
|
||||
sort_order = models.PositiveIntegerField(default=1000)
|
||||
related_article_ids = models.JSONField(default=list, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=ArticleStatus.choices,
|
||||
|
||||
@@ -58,7 +58,9 @@ class PublicPortalDetailView(PublicPortalView):
|
||||
return Response({"detail": t("portals.not_found")}, status=404)
|
||||
context, portal = resolved
|
||||
with tenant_atomic(context):
|
||||
counts = category_article_counts(portal, published_only=True)
|
||||
counts = category_article_counts(
|
||||
portal, published_only=True, locale=portal.default_locale
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"portal": public_portal_payload(portal),
|
||||
@@ -85,6 +87,7 @@ class PublicArticleListView(PublicPortalView):
|
||||
locale=locale,
|
||||
category=str(request.query_params.get("category", "")),
|
||||
query=str(request.query_params.get("q", "")).strip(),
|
||||
direct_category=request.query_params.get("direct") == "1",
|
||||
)
|
||||
try:
|
||||
limit = min(max(int(request.query_params.get("limit", 50)), 1), 100)
|
||||
@@ -122,7 +125,17 @@ class PublicArticleDetailView(PublicPortalView):
|
||||
).first()
|
||||
if article is None:
|
||||
return Response({"detail": t("portals.article_not_found")}, status=404)
|
||||
return Response({"article": public_article_payload(article)})
|
||||
payload = public_article_payload(article)
|
||||
related = public_articles(portal, locale=locale).filter(
|
||||
id__in=article.related_article_ids
|
||||
)
|
||||
by_id = {item.id: item for item in related}
|
||||
payload["relatedArticles"] = [
|
||||
public_article_payload(by_id[item_id], content=False)
|
||||
for item_id in article.related_article_ids
|
||||
if item_id in by_id
|
||||
]
|
||||
return Response({"article": payload})
|
||||
|
||||
|
||||
class PublicArticleFeedbackView(PublicPortalView):
|
||||
|
||||
@@ -108,6 +108,7 @@ def public_articles(
|
||||
locale: str,
|
||||
category: str = "",
|
||||
query: str = "",
|
||||
direct_category: bool = False,
|
||||
) -> QuerySet[PortalArticle]:
|
||||
articles = (
|
||||
PortalArticle.objects.filter(
|
||||
@@ -118,13 +119,13 @@ def public_articles(
|
||||
)
|
||||
.select_related("category", "published_revision")
|
||||
.prefetch_related("files")
|
||||
.order_by("category__sort_order", "published_revision__title")
|
||||
.order_by("category__sort_order", "sort_order", "published_revision__title", "id")
|
||||
)
|
||||
if category:
|
||||
root = portal.categories.filter(slug=category).first()
|
||||
if root is None:
|
||||
return articles.none()
|
||||
category_ids = descendant_category_ids(portal, root.id)
|
||||
category_ids = {root.id} if direct_category else descendant_category_ids(portal, root.id)
|
||||
articles = articles.filter(category_id__in=category_ids)
|
||||
if query:
|
||||
articles = articles.filter(
|
||||
@@ -151,10 +152,12 @@ def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]:
|
||||
|
||||
|
||||
def category_article_counts(
|
||||
portal: SupportPortal, *, published_only: bool = False
|
||||
portal: SupportPortal, *, published_only: bool = False, locale: str | None = None
|
||||
) -> dict[int, int]:
|
||||
categories = list(portal.categories.all())
|
||||
direct_query = PortalArticle.objects.filter(portal=portal)
|
||||
if locale is not None:
|
||||
direct_query = direct_query.filter(locale=locale)
|
||||
if published_only:
|
||||
direct_query = direct_query.filter(
|
||||
status="PUBLISHED", published_revision__isnull=False
|
||||
|
||||
@@ -107,6 +107,8 @@ def article_payload(
|
||||
"id": article.id,
|
||||
"slug": article.slug,
|
||||
"locale": article.locale,
|
||||
"sortOrder": article.sort_order,
|
||||
"relatedArticleIds": article.related_article_ids,
|
||||
"status": article.status,
|
||||
"category": category_payload(article.category),
|
||||
"publishedRevision": (
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
from chatballs.support_portals.tests.base import SupportPortalTestCase
|
||||
|
||||
|
||||
class ArticleCurationTests(SupportPortalTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
portal = self.create_portal().json()["portal"]
|
||||
self.portal_id = portal["id"]
|
||||
self.portal_host = portal["hostedDomain"]
|
||||
self.root = self._category("Начало", "start")
|
||||
self.child = self._category("Продолжение", "next", parent_id=self.root)
|
||||
|
||||
def _category(self, name: str, slug: str, *, parent_id: int | None = None) -> int:
|
||||
payload = {"name": name, "slug": slug}
|
||||
if parent_id is not None:
|
||||
payload["parentId"] = parent_id
|
||||
response = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/categories/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
return response.json()["category"]["id"]
|
||||
|
||||
def _article(self, slug: str, title: str, category_id: int) -> int:
|
||||
response = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/articles/",
|
||||
{
|
||||
"categoryId": category_id,
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"summary": title,
|
||||
"content": title,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
article = response.json()["article"]
|
||||
published = self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
|
||||
{"revisionId": article["revisions"][0]["id"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(published.status_code, 200, published.content)
|
||||
return article["id"]
|
||||
|
||||
def test_order_related_articles_and_direct_category(self) -> None:
|
||||
first = self._article("first", "Альфа", self.root)
|
||||
second = self._article("second", "Бета", self.root)
|
||||
child = self._article("child", "Гамма", self.child)
|
||||
base = f"/api/v1/support/portals/{self.portal_id}/articles/"
|
||||
for article_id, data in (
|
||||
(first, {"sortOrder": 2, "relatedArticleIds": [child, second]}),
|
||||
(second, {"sortOrder": 1}),
|
||||
):
|
||||
response = self.client.patch(
|
||||
f"{base}{article_id}/", data, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
invalid = self.client.patch(
|
||||
f"{base}{first}/",
|
||||
{"relatedArticleIds": [first]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/status/",
|
||||
{"status": "PUBLISHED"},
|
||||
format="json",
|
||||
)
|
||||
self.client.logout()
|
||||
listing = self.client.get(
|
||||
"/api/v1/help/articles/?category=start&direct=1",
|
||||
HTTP_HOST=self.portal_host,
|
||||
)
|
||||
self.assertEqual(listing.status_code, 200, listing.content)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in listing.json()["items"]],
|
||||
["second", "first"],
|
||||
)
|
||||
detail = self.client.get(
|
||||
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
|
||||
)
|
||||
self.assertEqual(detail.status_code, 200, detail.content)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
|
||||
["child", "second"],
|
||||
)
|
||||
|
||||
def test_curation_command_dry_run_and_apply(self) -> None:
|
||||
first = self._article("first", "Альфа", self.root)
|
||||
second = self._article("second", "Бета", self.root)
|
||||
self.client.post(
|
||||
f"/api/v1/support/portals/{self.portal_id}/status/",
|
||||
{"status": "PUBLISHED"},
|
||||
format="json",
|
||||
)
|
||||
plan = json.dumps({
|
||||
"portal": "app-help",
|
||||
"locale": "ru",
|
||||
"order": {"start": ["second", "first"]},
|
||||
"related": {"first": ["second"], "second": ["first"]},
|
||||
})
|
||||
output = StringIO()
|
||||
with patch("sys.stdin", StringIO(plan)):
|
||||
call_command(
|
||||
"apply_portal_curation", host=self.portal_host, file="-", stdout=output
|
||||
)
|
||||
self.assertIn("Would update 2 articles", output.getvalue())
|
||||
output = StringIO()
|
||||
with patch("sys.stdin", StringIO(plan)):
|
||||
call_command(
|
||||
"apply_portal_curation", host=self.portal_host, file="-", apply=True,
|
||||
stdout=output,
|
||||
)
|
||||
self.assertIn("Updated 2 articles", output.getvalue())
|
||||
self.client.logout()
|
||||
listing = self.client.get(
|
||||
"/api/v1/help/articles/?category=start&direct=1",
|
||||
HTTP_HOST=self.portal_host,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in listing.json()["items"]],
|
||||
["second", "first"],
|
||||
)
|
||||
detail = self.client.get(
|
||||
"/api/v1/help/articles/first/", HTTP_HOST=self.portal_host
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["slug"] for item in detail.json()["article"]["relatedArticles"]],
|
||||
["second"],
|
||||
)
|
||||
self.assertNotEqual(first, second)
|
||||
@@ -1,4 +1,4 @@
|
||||
# C06 tenant provisioning DB boundary (SPEC-HUB-0021 §10, ADR-CHATBALLS-0029 §6).
|
||||
# C06 tenant provisioning DB boundary (ADR-CHATBALLS-0029 §6).
|
||||
#
|
||||
# Provisioning runs on the platform connection (chatballs_runtime_platform) and must
|
||||
# both INSERT a new Organization and create tenant-owned rows (departments, the
|
||||
@@ -27,7 +27,7 @@ PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
|
||||
def apply_grants(apps, schema_editor):
|
||||
# 1. Platform role may create and update organizations (no RLS on this table;
|
||||
# it has no organization_id column). DELETE stays with the schema/migration
|
||||
# role per SPEC-HUB-0021 §14 (hard delete as compensation is forbidden).
|
||||
# role (hard delete as compensation is forbidden).
|
||||
schema_editor.execute(
|
||||
"GRANT INSERT, UPDATE ON identity_organization TO chatballs_runtime_platform"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Реестр демо-данных (identity_demodataset, identity_demorecord): tenant-таблицы
|
||||
# с organization_id, RLS и гранты по образцу tenancy/0021. Платформенная роль
|
||||
# получает те же права: мастер первого запуска ставит демо-набор в очередь на
|
||||
# соединении platform (SPEC-HUB-0021 §10).
|
||||
# соединении platform.
|
||||
from django.db import migrations
|
||||
|
||||
TABLES = ("identity_demodataset", "identity_demorecord")
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли
|
||||
# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому
|
||||
# уже проверка токена падала «permission denied for table platform_platformtoken»,
|
||||
# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты
|
||||
# а вместе с ней — весь POST /api/v1/organizations. Тесты
|
||||
# этого не видели: они ходят в базу владельцем кластера.
|
||||
#
|
||||
# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе
|
||||
# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию).
|
||||
# После первой организации INSERT для app закрыт навсегда — создавать следующие
|
||||
# по-прежнему может только роль platform (SPEC-HUB-0021 §10).
|
||||
# по-прежнему может только роль platform.
|
||||
from django.db import migrations
|
||||
|
||||
INGRESS_VIEWS = (
|
||||
|
||||
@@ -57,7 +57,7 @@ class OrganizationStorageUsage(models.Model):
|
||||
"""Authoritative storage_bytes usage counter for one organization.
|
||||
|
||||
``bytes_used`` is the committed total; ``reserved_bytes`` covers in-flight
|
||||
uploads whose final size is not yet known (SPEC-HUB-0022 §10 reserve/finalize).
|
||||
uploads whose final size is not yet known (reserve/finalize).
|
||||
The effective usage against the quota is ``bytes_used + reserved_bytes``.
|
||||
"""
|
||||
|
||||
@@ -88,7 +88,7 @@ class OrganizationStorageUsage(models.Model):
|
||||
|
||||
class StorageReservation(models.Model):
|
||||
"""An in-flight storage_bytes reservation keyed by an idempotency token, so a
|
||||
multi-step upload (SPEC-HUB-0022 §10) can reserve the expected size, finalize
|
||||
multi-step upload can reserve the expected size, finalize
|
||||
the actual size once the object is persisted, and release the reservation on
|
||||
failure. A single reservation tracks one upload lifecycle.
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Принудительный выбор соединения БД для блока кода.
|
||||
|
||||
Алиас ``platform`` (роль platform, права на создание организаций —
|
||||
SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной
|
||||
Алиас ``platform`` (роль platform, права на создание
|
||||
организаций) есть только у процессов, которым он нужен: у платформенной
|
||||
поверхности и у воркера, захватывающего outbox всех организаций
|
||||
(см. ``settings_database.build_databases``). У backend-app его нет: каталоги
|
||||
входа и мастер первого запуска работают ролью app (tenancy/0032).
|
||||
|
||||
@@ -13,7 +13,7 @@ from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
class StorageReserveFinalizeReleaseTests(TestCase):
|
||||
"""SPEC-HUB-0022 §10: reserve/finalize/release — идемпотентный технический
|
||||
"""Reserve/finalize/release — идемпотентный технический
|
||||
учёт занятого места. Лимитов больше нет (ADR-CHATBALLS-0042 §2): сервис никогда
|
||||
не отказывает по квоте."""
|
||||
|
||||
|
||||
@@ -208,9 +208,6 @@ CHATBALLS_AI_TURN_DEADLINE_SECONDS = int(
|
||||
)
|
||||
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
|
||||
|
||||
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
|
||||
# интеграцию организации (BYOK).
|
||||
|
||||
# Long-poll hold-time мессенджеров (сек). Держим малым: опрос идёт по
|
||||
# подключениям последовательно в одном процессе, и hold-time каждого из них
|
||||
# складывается в задержку приёма у остальных. Ответы AI от этого больше не
|
||||
@@ -267,7 +264,7 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
|
||||
testing=TESTING,
|
||||
)
|
||||
|
||||
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
|
||||
# Публичный адрес установки: абсолютные ссылки, уходящие клиентам (download вложений).
|
||||
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
|
||||
@@ -323,7 +320,7 @@ if (
|
||||
or CHATBALLS_CALL_CONNECT_GRACE_SECONDS <= 0
|
||||
or CHATBALLS_CALL_RECONNECT_GRACE_SECONDS <= 0
|
||||
):
|
||||
raise ImproperlyConfigured("HUB call token TTL values must be positive")
|
||||
raise ImproperlyConfigured("Call token TTL values must be positive")
|
||||
|
||||
# ICE-серверы для WebRTC (SPEC-CHATBALLS-0013 §10): direct-first через STUN, TURN как
|
||||
# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp).
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
/* Блокер «провайдер не выбран» (кадр G5, ADR-HUB-0017). */
|
||||
/* Блокер «провайдер не выбран» (кадр G5). */
|
||||
|
||||
.agent-blocker {
|
||||
display: flex;
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ChatPage({
|
||||
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
|
||||
: undefined}
|
||||
viewerId={user.id}
|
||||
sender={{ operatorName: user.fullName, company: user.organizationName }}
|
||||
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
|
||||
<ChatContextPanel
|
||||
rightTab={rightTab}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Icon, LogoSpinner } from "../../shared/icons";
|
||||
import { ComposerTemplatesMenu } from "./ComposerTemplatesMenu";
|
||||
import { EmojiPicker } from "./EmojiPicker";
|
||||
import { useMediaQuery } from "../../shared/useMediaQuery";
|
||||
import { fetchReplyTemplates, sendFileMessage, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
|
||||
import { sendFileMessage, sendOperatorMessage, sendVoiceMessage } from "./model";
|
||||
import { formatSize } from "../ai/knowledge/model";
|
||||
import { formatDuration } from "./VoiceMessage";
|
||||
import { TEMPLATE_VARIABLE_LABEL, unfilledVariables, type TemplateValues } from "./templateVariables";
|
||||
import { useComposerTemplates } from "./useComposerTemplates";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import type { ChannelKey, ControlMode } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, templateValues = {}, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; templateValues?: TemplateValues; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const compact = useMediaQuery("(max-width: 768px)");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState("");
|
||||
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const [attachment, setAttachment] = useState<File | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -35,25 +36,10 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
// Сброс черновика вложения при смене диалога.
|
||||
useEffect(() => { setAttachment(null); setSendError(""); }, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
|
||||
}, []);
|
||||
|
||||
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
|
||||
// продолжение ввода фильтрует по названию.
|
||||
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
|
||||
const visibleTemplates = useMemo(() => {
|
||||
if (templates.length === 0) return [];
|
||||
if (slashQuery === null) return templates;
|
||||
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
|
||||
}, [templates, slashQuery]);
|
||||
const menuOpen = templatesOpen || (slashQuery !== null && visibleTemplates.length > 0);
|
||||
|
||||
function applyTemplate(template: ReplyTemplateRef) {
|
||||
setText(template.text);
|
||||
setTemplatesOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
const templates = useComposerTemplates({ text, setText, values: templateValues, textareaRef });
|
||||
// Переменная шаблона без значения (у гостя нет имени) остаётся в тексте:
|
||||
// пока оператор её не заполнит, ответ не уходит.
|
||||
const unfilled = unfilledVariables(text);
|
||||
|
||||
// Запись голосового: во всех каналах (TG/MAX sendVoice, почта — вложением, Web — поллингом).
|
||||
const recorder = useVoiceRecorder({
|
||||
@@ -111,7 +97,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
|
||||
async function send() {
|
||||
const value = text.trim();
|
||||
if ((!value && !attachment) || conversationId == null || sending) return;
|
||||
if ((!value && !attachment) || conversationId == null || sending || unfilled.length > 0) return;
|
||||
setSending(true);
|
||||
setSendError("");
|
||||
try {
|
||||
@@ -163,7 +149,9 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
|
||||
// Кадры A–C: композер активен всегда (решение 2) — первое сообщение
|
||||
// перехватывает диалог; над полем одна строка-предупреждение.
|
||||
const warning = mode === "ai"
|
||||
const warning = unfilled.length > 0
|
||||
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.fill_template_variables", { names: unfilled.map((name) => t(TEMPLATE_VARIABLE_LABEL[name]).toLocaleLowerCase()).join(", ") }) }
|
||||
: mode === "ai"
|
||||
? { color: "var(--ai)", text: t("conversations.ai_handling_conversation_message_takes") }
|
||||
: mode === "waiting"
|
||||
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.customer_waiting_message_assigns_conversation") }
|
||||
@@ -174,17 +162,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
<div className="composer-wrap">
|
||||
{warning && <div className="composer-warning" style={{ color: warning.color }}><i style={{ background: warning.dot ?? warning.color }} />{warning.text}</div>}
|
||||
<div className="composer-box">
|
||||
{menuOpen && (
|
||||
<div className="composer-templates-menu">
|
||||
{visibleTemplates.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
|
||||
{visibleTemplates.map((template) => (
|
||||
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); applyTemplate(template); }}>
|
||||
<strong>{template.title}</strong>
|
||||
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{templates.menuOpen && <ComposerTemplatesMenu items={templates.visible} onPick={templates.apply} />}
|
||||
{attachment && (
|
||||
<div className="composer-attachment">
|
||||
<Icon name="paperclip" size={14} />
|
||||
@@ -202,14 +180,14 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && menuOpen) { setTemplatesOpen(false); if (slashQuery !== null) setText(""); return; }
|
||||
if (event.key === "Escape" && templates.menuOpen) { templates.close(); if (templates.slashQuery !== null) setText(""); return; }
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
if (slashQuery !== null && visibleTemplates.length > 0) { event.preventDefault(); applyTemplate(visibleTemplates[0]); return; }
|
||||
if (templates.slashQuery !== null && templates.visible.length > 0) { event.preventDefault(); templates.apply(templates.visible[0]); return; }
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
onBlur={() => setTemplatesOpen(false)}
|
||||
onBlur={templates.close}
|
||||
onPaste={(event) => {
|
||||
const file = Array.from(event.clipboardData?.files ?? [])[0];
|
||||
if (file) { event.preventDefault(); pickFile(file); }
|
||||
@@ -226,12 +204,12 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
<Icon name="mic" size={17} />
|
||||
</button>
|
||||
)}
|
||||
{templates.length > 0 && (
|
||||
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={() => setTemplatesOpen((open) => !open)}>
|
||||
{templates.available && (
|
||||
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={templates.toggle}>
|
||||
<Icon name="text" size={16} />{t("conversations.templates")}</button>
|
||||
)}
|
||||
<span className="composer-spacer" />
|
||||
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment)}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
|
||||
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment) || unfilled.length > 0}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReplyTemplateRef } from "./model";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function ComposerTemplatesMenu({ items, onPick }: { items: ReplyTemplateRef[]; onPick: (template: ReplyTemplateRef) => void }) {
|
||||
return (
|
||||
<div className="composer-templates-menu">
|
||||
{items.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
|
||||
{items.map((template) => (
|
||||
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); onPick(template); }}>
|
||||
<strong>{template.title}</strong>
|
||||
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,11 +53,11 @@ import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
|
||||
import { useIncomingMessageSound } from "./useIncomingMessageSound";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
|
||||
// Общий workspace диалогов. Видимость inbox решает
|
||||
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
|
||||
// placeholder поиска и правой панелью через render-prop. Список и история —
|
||||
// серверные окна: ни то, ни другое целиком не запрашивается.
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
|
||||
export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true, sender }: {
|
||||
isOwner?: boolean;
|
||||
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
|
||||
canDelete?: boolean;
|
||||
@@ -74,6 +74,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
setScope: (scope: DialogScope) => void;
|
||||
counters: ConversationCounters | null;
|
||||
showScopeSwitcher?: boolean;
|
||||
/** Кто отвечает — для переменных шаблонов ответов. */
|
||||
sender?: { operatorName: string; company: string };
|
||||
}) {
|
||||
const [listTab, setListTab] = useState<ListTab>("all");
|
||||
const [sort, setSort] = useState<ListSort>("activity");
|
||||
@@ -289,6 +291,11 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
mode={controlMode}
|
||||
channel={selectedDialog?.channel}
|
||||
voiceAllowed={detail?.connection?.voiceMessages ?? false}
|
||||
templateValues={{
|
||||
client_name: detail?.contact?.isGuest ? "" : detail?.contact?.name,
|
||||
operator_name: sender?.operatorName,
|
||||
company: sender?.company,
|
||||
}}
|
||||
loaded={detailLoaded}
|
||||
assignedOperatorName={detail?.assignedOperator?.name}
|
||||
conversationId={selectedId}
|
||||
|
||||
@@ -72,7 +72,8 @@ export type ApiConversation = {
|
||||
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
|
||||
// Контакт — единственный источник identity диалога.
|
||||
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
|
||||
// isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
controlMode: "AI" | "HUMAN" | "PAUSED";
|
||||
expectedResponder: string;
|
||||
@@ -371,6 +372,18 @@ export const createConversationLabel = (name: string, color = "") =>
|
||||
}).then((r) => r.label);
|
||||
export const fetchReplyTemplates = () =>
|
||||
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
|
||||
export const createReplyTemplate = (title: string, text: string) =>
|
||||
api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, text }),
|
||||
}).then((r) => r.template);
|
||||
export const updateReplyTemplate = (id: number, title: string, text: string) =>
|
||||
api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title, text }),
|
||||
}).then((r) => r.template);
|
||||
export const deleteReplyTemplate = (id: number) =>
|
||||
api<void>(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" });
|
||||
|
||||
export const transcribeMessage = (messageId: number) =>
|
||||
api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderTemplate, unfilledVariables } from "./templateVariables";
|
||||
|
||||
describe("переменные шаблона ответа", () => {
|
||||
it("подставляет известные значения", () => {
|
||||
const text = renderTemplate("Здравствуйте, {{client_name}}! Меня зовут {{ operator_name }}, {{company}}.", {
|
||||
client_name: "Дмитрий",
|
||||
operator_name: "Анна",
|
||||
company: "Ателье Норд",
|
||||
});
|
||||
expect(text).toBe("Здравствуйте, Дмитрий! Меня зовут Анна, Ателье Норд.");
|
||||
});
|
||||
|
||||
it("оставляет переменную без значения и чужие скобки как есть", () => {
|
||||
const text = renderTemplate("Здравствуйте, {{client_name}}! {{unknown}}", { client_name: " ", company: "Ателье Норд" });
|
||||
expect(text).toBe("Здравствуйте, {{client_name}}! {{unknown}}");
|
||||
});
|
||||
|
||||
it("находит незаполненные переменные без повторов", () => {
|
||||
expect(unfilledVariables("{{client_name}}, {{client_name}} и {{company}}, {{unknown}}")).toEqual(["client_name", "company"]);
|
||||
expect(unfilledVariables("Готовый текст")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MessageKey } from "../../i18n/ru";
|
||||
|
||||
// Переменные шаблонов ответов. В тексте шаблона хранится код, а не слово на
|
||||
// языке интерфейса: язык организации меняется, код — нет. Тот же список
|
||||
// проверяет сервер при сохранении (conversations/chat_extras_views.py).
|
||||
|
||||
export const TEMPLATE_VARIABLES = ["client_name", "operator_name", "company"] as const;
|
||||
|
||||
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
|
||||
export type TemplateValues = Partial<Record<TemplateVariable, string>>;
|
||||
|
||||
export const TEMPLATE_VARIABLE_LABEL: Record<TemplateVariable, MessageKey> = {
|
||||
client_name: "conversations.variable_client_name",
|
||||
operator_name: "conversations.variable_operator_name",
|
||||
company: "conversations.variable_company",
|
||||
};
|
||||
|
||||
const TOKEN = /\{\{\s*(\w+)\s*\}\}/g;
|
||||
|
||||
export function variableToken(variable: TemplateVariable): string {
|
||||
return `{{${variable}}}`;
|
||||
}
|
||||
|
||||
function isVariable(name: string): name is TemplateVariable {
|
||||
return (TEMPLATE_VARIABLES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** Подставляет известные значения. Переменная без значения (у гостя нет
|
||||
* имени) остаётся в тексте как есть — её заполнит оператор. */
|
||||
export function renderTemplate(text: string, values: TemplateValues): string {
|
||||
return text.replace(TOKEN, (token, name: string) => {
|
||||
const value = isVariable(name) ? values[name]?.trim() : "";
|
||||
return value || token;
|
||||
});
|
||||
}
|
||||
|
||||
/** Переменные, которые остались в тексте незаполненными, без повторов. */
|
||||
export function unfilledVariables(text: string): TemplateVariable[] {
|
||||
const found = [...text.matchAll(TOKEN)].map((match) => match[1]).filter(isVariable);
|
||||
return [...new Set(found)];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Общие типы workspace диалогов (sales + support). SPEC-HUB-0010 §8.2:
|
||||
// Общие типы workspace диалогов (sales + support):
|
||||
// общий conversation workspace, не отдельная реализация под каждый отдел.
|
||||
|
||||
export type DialogMode = "ai" | "closed" | "operator" | "wait";
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
|
||||
import { fetchReplyTemplates, type ReplyTemplateRef } from "./model";
|
||||
import { renderTemplate, type TemplateValues } from "./templateVariables";
|
||||
|
||||
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
|
||||
// продолжение ввода фильтрует по названию. Выбранный шаблон заменяет текст
|
||||
// поля, переменные подставляются сразу — оператор видит готовый ответ.
|
||||
|
||||
export function useComposerTemplates({ text, setText, values, textareaRef }: {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
values: TemplateValues;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
|
||||
}, []);
|
||||
|
||||
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
|
||||
const visible = useMemo(() => {
|
||||
if (templates.length === 0) return [];
|
||||
if (slashQuery === null) return templates;
|
||||
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
|
||||
}, [templates, slashQuery]);
|
||||
|
||||
function apply(template: ReplyTemplateRef) {
|
||||
setText(renderTemplate(template.text, values));
|
||||
setOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
available: templates.length > 0,
|
||||
visible,
|
||||
slashQuery,
|
||||
menuOpen: open || (slashQuery !== null && visible.length > 0),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
close: () => setOpen(false),
|
||||
apply,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { fetchHelpArticle, sendArticleFeedback } from "./api";
|
||||
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
|
||||
import { HelpLayout } from "./HelpLayout";
|
||||
import { HelpAttachments } from "./HelpAttachments";
|
||||
import { HelpRelatedArticles } from "./HelpRelatedArticles";
|
||||
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
|
||||
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
@@ -63,7 +64,9 @@ export function HelpArticle({
|
||||
return (
|
||||
<HelpLayout
|
||||
compactHeader
|
||||
portal={manifest.portal}
|
||||
manifest={manifest}
|
||||
activeCategoryId={article?.category.id}
|
||||
activeArticleSlug={article?.slug}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
onSearchSubmit={searchSubmit}
|
||||
@@ -128,6 +131,7 @@ export function HelpArticle({
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<HelpRelatedArticles articles={article.relatedArticles ?? []} />
|
||||
</article>
|
||||
{headings.length > 0 && (
|
||||
<aside className="help-toc" aria-label={t("portals.article_contents")}>
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { HelpManifest } from "./types";
|
||||
import "./styles-layout.css";
|
||||
import "./styles-home.css";
|
||||
import "./styles-article.css";
|
||||
import "./styles-navigation.css";
|
||||
import "./styles-related.css";
|
||||
import "./styles-responsive.css";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ export function HelpHome({ manifest }: { manifest: HelpManifest }) {
|
||||
|
||||
return (
|
||||
<HelpLayout
|
||||
portal={manifest.portal}
|
||||
manifest={manifest}
|
||||
activeCategoryId={selectedCategory?.id}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
>
|
||||
|
||||
@@ -2,26 +2,32 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { LogoIcon } from "../../shared/icons";
|
||||
import { HelpSearch } from "./HelpSearch";
|
||||
import { HelpNavigation } from "./HelpNavigation";
|
||||
import { PortalWebWidget } from "./PortalWebWidget";
|
||||
import type { HelpPortal } from "./types";
|
||||
import type { HelpManifest } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export function HelpLayout({
|
||||
portal,
|
||||
manifest,
|
||||
children,
|
||||
search,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
compactHeader = false,
|
||||
activeCategoryId = null,
|
||||
activeArticleSlug = null,
|
||||
}: {
|
||||
portal: HelpPortal;
|
||||
manifest: HelpManifest;
|
||||
children: ReactNode;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSearchSubmit?: (value: string) => void;
|
||||
compactHeader?: boolean;
|
||||
activeCategoryId?: number | null;
|
||||
activeArticleSlug?: string | null;
|
||||
}) {
|
||||
const homeHref = "/";
|
||||
const portal = manifest.portal;
|
||||
return (
|
||||
<div className={`help-center ${compactHeader ? "has-compact-header" : ""}`}>
|
||||
<header className="help-header">
|
||||
@@ -40,7 +46,14 @@ export function HelpLayout({
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
<main className="help-page-grid">
|
||||
<HelpNavigation
|
||||
manifest={manifest}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeArticleSlug={activeArticleSlug}
|
||||
/>
|
||||
<div className="help-page-content">{children}</div>
|
||||
</main>
|
||||
<footer className="help-footer">
|
||||
<div className="help-footer-row">
|
||||
<a className="help-footer-brand" href={homeHref}>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { fetchHelpArticles } from "./api";
|
||||
import { HelpChevronIcon } from "./HelpIcons";
|
||||
import type { HelpArticle, HelpCategory, HelpManifest } from "./types";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
function articleHref(slug: string): string {
|
||||
return `/articles/${encodeURIComponent(slug)}/`;
|
||||
}
|
||||
|
||||
type BranchProps = {
|
||||
category: HelpCategory;
|
||||
childrenByParent: Map<number | null, HelpCategory[]>;
|
||||
activePath: Set<number>;
|
||||
activeCategoryId: number | null;
|
||||
activeArticleSlug: string | null;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
function CategoryBranch({
|
||||
category, childrenByParent, activePath, activeCategoryId, activeArticleSlug, locale,
|
||||
}: BranchProps) {
|
||||
const [expanded, setExpanded] = useState(activePath.has(category.id));
|
||||
const [articles, setArticles] = useState<HelpArticle[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const children = childrenByParent.get(category.id) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (activePath.has(category.id)) setExpanded(true);
|
||||
}, [activePath, category.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || articles !== null || category.articleCount === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchHelpArticles({ locale, category: category.slug, direct: true, limit: 50 })
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setArticles(payload.items);
|
||||
setHasMore(payload.pagination.hasMore);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setFailed(true); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [articles, category.articleCount, category.slug, expanded, locale]);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !articles) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await fetchHelpArticles({
|
||||
locale, category: category.slug, direct: true, limit: 50, offset: articles.length,
|
||||
});
|
||||
setArticles((current) => [...(current ?? []), ...payload.items]);
|
||||
setHasMore(payload.pagination.hasMore);
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canExpand = category.articleCount > 0 || children.length > 0;
|
||||
return (
|
||||
<li className="help-nav-branch">
|
||||
<div className="help-nav-row">
|
||||
<a
|
||||
aria-current={activeCategoryId === category.id && !activeArticleSlug ? "page" : undefined}
|
||||
className={activeCategoryId === category.id ? "is-current" : ""}
|
||||
href={`/?category=${encodeURIComponent(category.slug)}`}
|
||||
>{category.name}</a>
|
||||
{canExpand && (
|
||||
<button
|
||||
aria-label={t(expanded ? "portals.collapse_section" : "portals.expand_section", { name: category.name })}
|
||||
aria-expanded={expanded}
|
||||
className={expanded ? "is-expanded" : ""}
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
><HelpChevronIcon /></button>
|
||||
)}
|
||||
</div>
|
||||
{canExpand && expanded && (
|
||||
<ul className="help-nav-children">
|
||||
{articles?.map((article) => (
|
||||
<li key={article.slug}>
|
||||
<a
|
||||
aria-current={activeArticleSlug === article.slug ? "page" : undefined}
|
||||
className={`help-nav-article${activeArticleSlug === article.slug ? " is-current" : ""}`}
|
||||
href={articleHref(article.slug)}
|
||||
>{article.revision.title}</a>
|
||||
</li>
|
||||
))}
|
||||
{children.map((child) => (
|
||||
<CategoryBranch
|
||||
key={child.id}
|
||||
category={child}
|
||||
childrenByParent={childrenByParent}
|
||||
activePath={activePath}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeArticleSlug={activeArticleSlug}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
{loading && <li className="help-nav-status" aria-live="polite">…</li>}
|
||||
{failed && <li className="help-nav-status">{t("portals.could_not_load_articles")}</li>}
|
||||
{hasMore && !loading && (
|
||||
<li><button className="help-nav-more" type="button" onClick={() => void loadMore()}>{t("portals.show_more")}</button></li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpNavigation({
|
||||
manifest, activeCategoryId, activeArticleSlug,
|
||||
}: {
|
||||
manifest: HelpManifest;
|
||||
activeCategoryId: number | null;
|
||||
activeArticleSlug: string | null;
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const childrenByParent = useMemo(() => {
|
||||
const result = new Map<number | null, HelpCategory[]>();
|
||||
for (const category of manifest.categories) {
|
||||
const siblings = result.get(category.parentId) ?? [];
|
||||
siblings.push(category);
|
||||
result.set(category.parentId, siblings);
|
||||
}
|
||||
for (const siblings of result.values()) {
|
||||
siblings.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
}
|
||||
return result;
|
||||
}, [manifest.categories]);
|
||||
const activePath = useMemo(() => {
|
||||
const byId = new Map(manifest.categories.map((category) => [category.id, category]));
|
||||
const path = new Set<number>();
|
||||
let current = activeCategoryId ? byId.get(activeCategoryId) : undefined;
|
||||
while (current && !path.has(current.id)) {
|
||||
path.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path;
|
||||
}, [activeCategoryId, manifest.categories]);
|
||||
|
||||
return (
|
||||
<aside className="help-navigation">
|
||||
<button
|
||||
aria-controls="help-navigation-tree"
|
||||
aria-expanded={mobileOpen}
|
||||
className="help-navigation-mobile-toggle"
|
||||
type="button"
|
||||
onClick={() => setMobileOpen((value) => !value)}
|
||||
>{t("portals.all_sections")}<HelpChevronIcon /></button>
|
||||
<nav aria-label={t("portals.navigation")} className={mobileOpen ? "is-open" : ""} id="help-navigation-tree">
|
||||
<a className="help-nav-all" href="/">{t("portals.all_sections")}</a>
|
||||
<ul>
|
||||
{(childrenByParent.get(null) ?? []).map((category) => (
|
||||
<CategoryBranch
|
||||
key={category.id}
|
||||
category={category}
|
||||
childrenByParent={childrenByParent}
|
||||
activePath={activePath}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeArticleSlug={activeArticleSlug}
|
||||
locale={manifest.portal.defaultLocale}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user