mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aff0c564b | ||
|
|
0d307c7d06 | ||
|
|
4e56b10e6a | ||
|
|
bcb66fadbc |
No files matched your search
@@ -34,15 +34,21 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
|
||||
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
|
||||
# каждой организации свой, и отозванный ключ одной не имеет отношения к 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 +57,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 +79,7 @@ class ChatJob:
|
||||
model: str
|
||||
messages: list[ChatMessage]
|
||||
breaker_key: tuple[int, int]
|
||||
breaker_revision: int
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
@@ -83,6 +91,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 +118,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 +135,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 +211,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 +227,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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -494,6 +494,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",
|
||||
|
||||
@@ -498,6 +498,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": "Укажите название",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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),
|
||||
),
|
||||
]
|
||||
@@ -69,6 +69,9 @@ class Integration(models.Model):
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -273,6 +273,9 @@ LOADER_JS = r"""
|
||||
|
||||
function setOpen(next) {
|
||||
ensureFrame();
|
||||
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
|
||||
// запуска анимации, чтобы панель не мелькнула целиком.
|
||||
ensureGenie();
|
||||
open = next;
|
||||
window.clearTimeout(contentTimer);
|
||||
btn.style.opacity = "0";
|
||||
|
||||
Reference in new issue
Block a user