Compare commits

...
6 Commits
Author SHA1 Message Date
Andrey cce87a4973 🔖 release: 1.14.3 2026-09-22 07:29:25 +03:00
Andrey 556246865e 🚨 fix(lint): исправить сортировку импортов 2026-09-22 07:29:03 +03:00
Andrey 0aff0c564b 🔖 release: 1.14.2 2026-09-22 07:13:46 +03:00
Andrey 0d307c7d06 🐛 fix(ai): сбрасывать circuit breaker после настройки провайдера
Повторы одного вызова теперь считаются одним логическим сбоем, поэтому предохранитель не открывается посреди второго сообщения. Изменение runtime-конфигурации и успешная проверка увеличивают ревизию интеграции: каждый event-worker заменяет открытый breaker при следующем запросе.

Удаление используемого провайдера возвращает 409 с понятной причиной и не пишет ложное событие об успешном удалении. Добавлены миграция и регрессионные тесты.
2026-09-22 07:13:20 +03:00
AndreyandClaude Opus 5 4e56b10e6a 🔖 release: 1.14.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
AndreyandClaude Opus 5 bcb66fadbc 🐛 fix(webchat): первое открытие чата — с анимацией и кнопкой «свернуть»
Первый кадр силуэта рисовался раньше, чем создавался слой, на котором он
живёт. Обращение к нему падало, и остаток обработчика не выполнялся: окно
появлялось рывком, а кнопка оставалась со знаком агента вместо шеврона —
свернуть чат было нечем. Со второго раза слой уже существовал, и всё
работало, поэтому дефект был виден только при первом открытии.

Слой создаётся вместе с панелью, до первого кадра.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:49:00 +03:00
23 changed files with 412 additions and 116 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.14.0
1.14.3
+28 -14
View File
@@ -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),
)
@@ -38,17 +38,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
@@ -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()
+4 -2
View File
@@ -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
)
@@ -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:
@@ -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):
@@ -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"
@@ -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,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),
),
]
@@ -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
+13 -78
View File
@@ -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()
)
+20 -5
View File
@@ -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)
+3
View File
@@ -273,6 +273,9 @@ LOADER_JS = r"""
function setOpen(next) {
ensureFrame();
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
// запуска анимации, чтобы панель не мелькнула целиком.
ensureGenie();
open = next;
window.clearTimeout(contentTimer);
btn.style.opacity = "0";